elsa-core/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Broker/BrokerSecurityTests.cs

589 lines
34 KiB
C#
Raw Normal View History

feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
using Elsa.Testing.Shared.Multitenancy;
using System.Security.Cryptography;
using System.Text;
2026-07-24 16:59:17 +00:00
using System.Text.Json;
using Elsa.Common;
using Elsa.Common.Multitenancy;
using Elsa.Common.Services;
2026-07-24 16:59:17 +00:00
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.Models;
using Elsa.ExternalAuthentication.Notifications;
using Elsa.ExternalAuthentication.Options;
using Elsa.ExternalAuthentication.Policies;
2026-07-24 16:59:17 +00:00
using Elsa.ExternalAuthentication.Services;
using Elsa.ExternalAuthentication.Stores.InMemory;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using Elsa.Identity.Options;
using Elsa.Identity.Providers;
using Elsa.Identity.Services;
2026-07-24 16:59:17 +00:00
using Elsa.Mediator.Contracts;
using Elsa.Workflows;
2026-07-24 16:59:17 +00:00
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
2026-07-24 16:59:17 +00:00
using NSubstitute;
namespace Elsa.ExternalAuthentication.IntegrationTests.Broker;
public class BrokerSecurityTests
{
[Fact]
public async Task LocalAuthorizationCodeExchangeAndRefreshResolveCurrentPermissionsInTheTokenTenant()
{
const string verifier = "local-login-code-verifier";
var challenge = Convert.ToBase64String(SHA256.HashData(Encoding.ASCII.GetBytes(verifier)))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
var tenantAccessor = new DefaultTenantAccessor();
var user = new User { Id = "user-a", Name = "admin", TenantId = "tenant-a", Roles = ["admin"] };
var role = new Role { Id = "admin", Name = "Administrator", TenantId = "tenant-a", Permissions = ["*"] };
var credentials = Substitute.For<IUserCredentialsValidator>();
credentials.ValidateAsync("admin", "password", Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult<User?>(user));
var users = Substitute.For<IUserProvider>();
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(tenantAccessor.TenantId == "tenant-a" ? user : null));
var roles = Substitute.For<IRoleProvider>();
roles.FindManyAsync(Arg.Any<RoleFilter>(), Arg.Any<CancellationToken>())
.Returns(_ => ValueTask.FromResult<IEnumerable<Role>>(tenantAccessor.TenantId == "tenant-a" ? [role] : []));
var tokenOptions = Microsoft.Extensions.Options.Options.Create(new IdentityTokenOptions
{
SigningKey = "local-external-authentication-test-signing-key",
Issuer = "https://elsa.test",
Audience = "elsa-api"
});
var tokens = new DefaultElsaTokenService(new CurrentTestClock(), tokenOptions);
var refreshTokens = new DefaultIdentityRefreshTokenService(users, new DefaultAccessTokenIssuer(roles, tokens), tenantAccessor, tokenOptions);
var externalTokenIssuer = Substitute.For<IExternalAuthenticationTokenIssuer>();
externalTokenIssuer.RefreshAsync("studio", Arg.Any<SensitiveString>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromException<ExternalTokenResponse>(new InvalidOperationException("A local refresh token must not use the external-session issuer.")));
var broker = CreateBroker(
new RecordingAdapter(),
tokenIssuer: externalTokenIssuer,
credentialsValidator: credentials,
userProvider: users,
roleProvider: roles,
tokenService: tokens,
identityRefreshTokenService: refreshTokens,
tenantAccessor: tenantAccessor);
BrokerCallbackResult authorization;
using (tenantAccessor.PushContext(new Tenant { Id = "tenant-a", Name = "Tenant A" }))
{
authorization = await broker.InitiateLocalAsync(
new LocalBrokerAuthorizationRequest(
"studio",
new Uri("https://studio.example/authentication/external/callback"),
"code",
challenge,
"S256",
"/workflows",
"admin",
"password"),
"tenant-a");
}
var code = Query(authorization.RedirectUri!, "code");
BrokerTokenResult exchange;
using (tenantAccessor.PushContext(new Tenant { Id = "tenant-b", Name = "Tenant B" }))
{
exchange = await broker.ExchangeAsync(new BrokerTokenRequest(
"authorization_code",
"studio",
new Uri("https://studio.example/authentication/external/callback"),
code,
verifier,
null,
"https://studio.example"));
Assert.Equal("tenant-b", tenantAccessor.TenantId);
}
Assert.Null(exchange.Error);
var accessToken = new JsonWebTokenHandler().ReadJsonWebToken(exchange.Token!.AccessToken);
Assert.Contains(accessToken.Claims, claim => claim.Type == JwtRegisteredClaimNames.Sub && claim.Value == user.Id);
Assert.Contains(accessToken.Claims, claim => claim.Type == "permissions" && claim.Value == "*");
role.Permissions = ["workflows:manage"];
var refresh = await broker.ExchangeAsync(new BrokerTokenRequest(
"refresh_token",
"studio",
null,
null,
null,
exchange.Token.RefreshToken,
"https://studio.example"));
Assert.Null(refresh.Error);
Assert.NotNull(refresh.Token);
Assert.True(exchange.Token.RefreshExpiresIn > 0);
Assert.True(refresh.Token.RefreshExpiresIn > 0);
var refreshedAccessToken = new JsonWebTokenHandler().ReadJsonWebToken(refresh.Token.AccessToken);
Assert.Contains(refreshedAccessToken.Claims, claim => claim.Type == "permissions" && claim.Value == "workflows:manage");
Assert.DoesNotContain(refreshedAccessToken.Claims, claim => claim.Type == "permissions" && claim.Value == "*");
await externalTokenIssuer.DidNotReceive().RefreshAsync(Arg.Any<string>(), Arg.Any<SensitiveString>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task LocalInitiationTreatsNullUserTenantAsTheDefaultTenant()
{
var credentials = Substitute.For<IUserCredentialsValidator>();
credentials.ValidateAsync("admin", "password", Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult<User?>(new User { Id = "admin", Name = "admin" }));
var broker = CreateBroker(new RecordingAdapter(), credentialsValidator: credentials);
var request = new LocalBrokerAuthorizationRequest(
"studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", "/workflows", "admin", "password", "state");
var result = await broker.InitiateLocalAsync(request, Tenant.DefaultTenantId);
Assert.Null(result.Error);
Assert.StartsWith("https://studio.example/authentication/external/callback?code=", result.RedirectUri?.AbsoluteUri);
Assert.Contains("state=state", result.RedirectUri?.Query);
}
[Fact]
public async Task OpaqueRefreshTokensContinueToUseTheExternalSessionIssuer()
{
var expected = new ExternalTokenResponse("access", "Bearer", 300, "session.rotated", 600, 600);
var externalTokenIssuer = Substitute.For<IExternalAuthenticationTokenIssuer>();
externalTokenIssuer.RefreshAsync("studio", Arg.Any<SensitiveString>(), Arg.Any<CancellationToken>()).Returns(expected);
var identityRefreshTokenService = Substitute.For<IIdentityRefreshTokenService>();
var broker = CreateBroker(
new RecordingAdapter(),
tokenIssuer: externalTokenIssuer,
identityRefreshTokenService: identityRefreshTokenService);
var result = await broker.ExchangeAsync(new BrokerTokenRequest(
"refresh_token",
"studio",
null,
null,
null,
"session.random",
"https://studio.example"));
Assert.Null(result.Error);
Assert.Same(expected, result.Token);
await identityRefreshTokenService.DidNotReceive().RefreshAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task ExternalInitiationUsesExactlyOneOpaqueProviderStateAndPersistsAdapterPayload()
{
var adapter = new RecordingAdapter();
var broker = CreateBroker(adapter);
var request = Request("/workflows");
var result = await broker.InitiateExternalAsync(request, "tenant-a");
Assert.Null(result.Error);
Assert.NotNull(result.NavigationUri);
Assert.Equal(adapter.CorrelationState, Query(result.NavigationUri!, "state"));
Assert.NotEqual(adapter.Transaction!.HandleHash, adapter.CorrelationState);
Assert.NotEqual([1, 2, 3], adapter.Transaction.ProtectedPayload);
}
[Theory]
[InlineData("//evil.example")]
[InlineData("/administration")]
public async Task InitiationRejectsReturnPathsOutsideTheAuthenticationClientAllowlist(string returnPath)
{
var adapter = new RecordingAdapter();
var broker = CreateBroker(adapter);
var result = await broker.InitiateExternalAsync(Request(returnPath), "tenant-a");
Assert.Equal("invalid_request", result.Error?.Error);
Assert.Null(adapter.CorrelationState);
}
[Fact]
public async Task CallbackFailureAfterTrustedStateRedirectsOnlyToRegisteredCallback()
{
var adapter = new RecordingAdapter { ThrowOnCallback = true };
var broker = CreateBroker(adapter);
var initiated = await broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
2026-07-24 16:59:17 +00:00
Assert.Equal("authentication_failed", result.Error?.Error);
Assert.StartsWith("https://studio.example/authentication/external/callback?", result.RedirectUri?.AbsoluteUri);
Assert.DoesNotContain("issuer.example", result.RedirectUri?.AbsoluteUri);
}
[Fact]
public async Task SuccessfulExternalSignInRecordsTheTimestampForAnExistingIdentityLink()
{
var scenario = CreateIdentityLinkTrackingScenario();
var existing = await scenario.Provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", scenario.Identity, new UserCreationProposal("external")));
var signedInAt = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
scenario.Clock.UtcNow = signedInAt;
var result = await CompleteExternalSignInAsync(scenario);
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
Assert.Null(result.Error);
Assert.Equal(existing.Link.Id, link?.Id);
Assert.Equal(signedInAt, link?.LastSignedInAt);
}
[Fact]
public async Task SuccessfulExternalSignInRecordsTheInitialTimestampForANewIdentityLink()
{
var scenario = CreateIdentityLinkTrackingScenario();
var signedInAt = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
scenario.Clock.UtcNow = signedInAt;
var result = await CompleteExternalSignInAsync(scenario);
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
Assert.Null(result.Error);
Assert.NotNull(link);
Assert.Equal(signedInAt, link.LastSignedInAt);
}
[Fact]
public async Task RepeatSuccessfulExternalSignInReplacesTheIdentityLinkTimestamp()
{
var scenario = CreateIdentityLinkTrackingScenario();
await scenario.Provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", scenario.Identity, new UserCreationProposal("external")));
var firstSignInAt = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
scenario.Clock.UtcNow = firstSignInAt;
await CompleteExternalSignInAsync(scenario);
var secondSignInAt = firstSignInAt.AddMinutes(1);
scenario.Clock.UtcNow = secondSignInAt;
var result = await CompleteExternalSignInAsync(scenario);
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
Assert.Null(result.Error);
Assert.Equal(secondSignInAt, link?.LastSignedInAt);
}
[Fact]
public async Task UnsuccessfulExternalSignInDoesNotRecordTheIdentityLinkTimestamp()
{
var scenario = CreateIdentityLinkTrackingScenario(throwOnCallback: true);
await scenario.Provisioner.CreateLinkOrGetExistingAsync(new ProvisioningRequest("tenant-a", "contoso", scenario.Identity, new UserCreationProposal("external")));
scenario.Clock.UtcNow = new DateTimeOffset(2026, 1, 1, 0, 1, 0, TimeSpan.Zero);
var result = await CompleteExternalSignInAsync(scenario);
var link = await scenario.Provisioner.FindLinkAsync("tenant-a", "contoso", scenario.Identity);
Assert.Equal("authentication_failed", result.Error?.Error);
Assert.Null(link?.LastSignedInAt);
}
2026-07-24 16:59:17 +00:00
[Fact]
public async Task ProviderCallbackStateCannotBeReplayed()
{
var adapter = new RecordingAdapter { ThrowOnCallback = true };
var broker = CreateBroker(adapter);
await broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
var parameters = new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] };
_ = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, parameters);
var replay = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, parameters);
2026-07-24 16:59:17 +00:00
Assert.Equal("invalid_request", replay.Error?.Error);
Assert.Null(replay.RedirectUri);
}
[Theory]
[InlineData("revision")]
[InlineData("disabled")]
[InlineData("archived")]
public async Task CallbackRejectsConnectionChangesAfterInitiation(string change)
{
var adapter = new RecordingAdapter { ThrowOnCallback = true };
var broker = CreateBroker(adapter);
await broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
var connection = adapter.Connection!.Connection;
switch (change)
{
case "revision": connection.MaterialRevision = "revision-b"; break;
case "disabled": connection.IsEnabled = false; break;
case "archived": connection.ArchivedAt = DateTimeOffset.UtcNow; break;
}
var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
2026-07-24 16:59:17 +00:00
Assert.Equal(change == "revision" ? "flow_changed" : "method_unavailable", result.Error?.Error);
Assert.StartsWith("https://studio.example/authentication/external/callback?", result.RedirectUri?.AbsoluteUri);
}
[Fact]
public async Task RefreshRotationRevokesTheSessionWhenAnOlderTokenIsReused()
{
var clock = new TestClock();
var store = new InMemoryExternalAuthenticationSessionStore(clock);
var connection = new IdentityProviderConnection { Id = "connection-a", TenantId = "tenant-a", Key = "contoso", AdapterType = "fake", DisplayName = "Contoso", IsEnabled = true, MaterialRevision = "revision-a" };
var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, new(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "test");
var registry = Substitute.For<IIdentityProviderConnectionRegistry>();
registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
2026-07-24 16:59:17 +00:00
var users = Substitute.For<IUserProvider>();
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult<User?>(new User { Id = "user-a", Name = "alice", TenantId = "tenant-a" }));
var roles = Substitute.For<IRoleProvider>();
roles.FindManyAsync(Arg.Any<RoleFilter>(), Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<IEnumerable<Role>>([]));
var tokens = Substitute.For<IElsaTokenService>();
tokens.IssueAccessTokenAsync(Arg.Any<TokenIssuanceContext>(), Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult(new IssuedAccessToken("access", clock.UtcNow.AddHours(1))));
fix(external-auth)!: wildcard-aware permission grant boundary, and startup smoke tests for both hosts (#7985) * fix(external-auth)!: match permission grant boundaries as patterns The deployment allow/deny boundary and the delegation authorizer compared permission strings with ordinal equality, so under the {resource}:{verb} vocabulary they could not see wildcards. A deny list naming 'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant of 'workflows/*:delete' outflanked a deny naming that leaf. The bypass was reachable. ElsaRolePermissionGrantSource passes a role's permissions to the boundary verbatim, survivors land in the issued token as permission claims, and PermissionEvaluator does expand wildcards there. So an ordinary role plus a deny list was enough, on every external sign-in, with no privileged actor involved. Restoring the ordinal boundary under the new tests fails seven of them. Deny is now matched in both directions, allow one-directionally, both through PermissionMatcher. A grant that is not a well-formed permission is dropped with a warning rather than carried into a token it cannot authorize anything in. Five non-endpoint checks -- delegation, role-reference removal, unsafe settings confirmation, the recovery override and the boundary itself -- also still compared against the legacy ExternalAuthenticationPermissions constants. Those carry two colons, so Permission.TryParse rejects them and no principal can hold one, while the migration guide tells operators to replace exactly those strings. All five now route through IPermissionEvaluator, and the module registers AddElsaAuthorization itself instead of depending on host ordering. Non-core verbs move to ExternalAuthenticationVerbs, declared beside the resources they apply to so a delegation check cannot spell one differently from the endpoint it guards. Refs #7982 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style: apply IDE code cleanup to the diagnostics and identity modules Redundant namespace qualifiers and usings removed, and primary-constructor and record syntax applied, across Elsa.Diagnostics.ConsoleLogs, Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the authorization work; separated from it so the permission changes can be reviewed on their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(hosts): boot both hosts and assert their gated routes challenge This repo runs two parallel feature systems, the classic Features/ path and the CShells ShellFeatures/ path, and every module has to register in both. Nothing exercised either. The unit and integration suites construct services directly, so a module registered in one path and not the other, or a service missing from one container, passes every test and fails only when a host starts. Three bugs in #7980 were found by running these two hosts by hand, two of them shell-versus-classic divergences. Each host is booted through WebApplicationFactory, running its real Program with full feature registration, and asked for a handful of routes it is expected to serve behind a permission. A 404 means the module was never registered, a 5xx means the endpoint was found but its dependencies could not be constructed, and a 200 means no gate ran; only 401 passes. All routes are reported together, so a feature system that stops registering a group of modules reads as one failure rather than a queue of identical ones. Removing AddExternalAuthenticationServices from the shell feature -- the divergence this is built to catch -- fails the shell host on all five of its routes while the classic host stays green. The assertions go through HTTP rather than the container on purpose. The hosts have different topologies: the classic host's root provider holds everything and registers 125 routes, while CShells gives each shell its own provider and mounts routes per shell, leaving 6 in the root. A container or route-table assertion would have to encode that difference and would break whenever CShells changed internally. Behaviour at the edge is host-agnostic, and it is what actually has to match. Each host gains a namespaced entry-point marker because both already declare a Program in the global namespace, which a test project referencing both cannot tell apart. Coverage is off for this project: it references both hosts, so every module either pulls in would enter its denominator without adding real coverage, and coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's /p:CollectCoverage=true from overriding that. Refs #7982 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(external-auth)!: fail closed on an unparseable grant boundary Two findings from review, both real. The grant boundary parsed its allow and deny lists and silently dropped what would not parse. An allow list of nothing but malformed entries therefore reduced to an empty set, and an empty allow list means unrestricted -- so a typo turned the boundary off entirely and let external grant sources put permissions straight into issued tokens. The deny side had the mirror of it: a malformed entry quietly stopped denying what it named. A boundary that does not parse now admits nothing, and ExternalAuthenticationOptionsValidator rejects the configuration at startup, so the mistake reaches an operator rather than a token. Failing startup is what makes the runtime behaviour safe to be strict about: it cannot be hit by someone mid-edit, only by validation having been bypassed. ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check, missed when the other five were converted. It compared claim values against the legacy ExternalAuthenticationPermissions constants at four call sites -- policy management on create and update, session revocation, and unsafe settings confirmation -- and those constants carry two colons, so nothing can hold one once a deployment follows the migration guide. It now routes through IPermissionEvaluator like the rest, resolved from the request with a fallback to the shared evaluator, the same way EndpointSecurity does it. Refs #7982 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(external-auth): filter permission patterns with Where Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged: a null list still iterates nothing, only malformed entries are reported, and the message text is identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(external-auth)!: apply the grant boundary to role permissions too Token issuance concatenated the user's Elsa role permissions raw alongside the boundary-filtered external grants. A permission the boundary had just excluded during grant resolution therefore reappeared in the issued token from the same roles, which made the deny list unenforceable for anything a role carried and left ElsaRolePermissionGrantSource filtering nothing that was not added back a moment later. The bypass did not even need that grant source configured: role permissions reached the token regardless of which sources a connection selected. Both origins now pass the same boundary. Re-applying it at issuance also picks up a boundary that changed since sign-in, since refreshing reissues. This is a behaviour change for deployments that configured a boundary expecting it to bound only claim-mapped permissions: an external login may now carry fewer permissions than before. Deployments with no boundary configured, the default, are unaffected -- every well-formed permission passes. The migration guide describes both directions. Refs #7982 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:25:29 +00:00
var issuer = new DefaultExternalAuthenticationTokenIssuer(store, registry, [], users, roles, tokens, new DefaultTenantAccessor(), clock, Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions()));
var session = new ExternalAuthenticationSession { Id = "session-a", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a", ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a", SecretGenerationFingerprint = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData([])), Issuer = "issuer", SubjectHash = "subject", StartedAt = clock.UtcNow, LastRefreshedAt = clock.UtcNow, ExpiresAt = clock.UtcNow.AddHours(1), RefreshExpiresAt = clock.UtcNow.AddHours(1) };
2026-07-24 16:59:17 +00:00
var first = await issuer.IssueAsync(session);
var second = await issuer.RefreshAsync("studio", new SensitiveString(first.RefreshToken));
await Assert.ThrowsAsync<InvalidOperationException>(() => issuer.RefreshAsync("studio", new SensitiveString(first.RefreshToken)).AsTask());
var revoked = await store.FindByIdAsync(session.Id);
Assert.NotEqual(first.RefreshToken, second.RefreshToken);
Assert.NotNull(revoked?.RevokedAt);
Assert.Equal("refresh_token_reuse", revoked?.RevocationReason);
}
[Fact]
public async Task PkceMismatchConsumesTheAuthorizationCode()
{
var grants = new InMemoryAuthorizationGrantStore(new TestClock());
var broker = CreateBroker(new RecordingAdapter(), grants, new FixedHasher());
await grants.SaveAsync(new AuthorizationGrant { CodeHash = "hash:code", ClientId = "studio", CallbackUri = new Uri("https://studio.example/authentication/external/callback"), TenantId = "tenant-a", UserId = "user-a", PkceChallenge = "expected", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(1) });
var result = await broker.ExchangeAsync(new BrokerTokenRequest("authorization_code", "studio", new Uri("https://studio.example/authentication/external/callback"), "code", "wrong", null, "https://studio.example"));
var after = await grants.TryTakeAsync("hash:code");
Assert.Equal("invalid_request", result.Error?.Error);
Assert.IsType<TakeResult<AuthorizationGrant>.AlreadyConsumed>(after);
}
[Fact]
public async Task InitiationRejectsAnExactCallbackUriMismatch()
{
var adapter = new RecordingAdapter();
var broker = CreateBroker(adapter);
var result = await broker.InitiateExternalAsync(new BrokerAuthorizationRequest("studio", new Uri("https://studio.example/other"), "code", "challenge", "S256", "/workflows", "contoso"), "tenant-a");
Assert.Equal("invalid_request", result.Error?.Error);
Assert.Null(adapter.CorrelationState);
}
[Fact]
public async Task ExchangeRequiresAnExactPublicOriginAndConfidentialBasicClientId()
{
var publicBroker = CreateBroker(new RecordingAdapter());
var publicResult = await publicBroker.ExchangeAsync(new BrokerTokenRequest("authorization_code", "studio", new Uri("https://studio.example/authentication/external/callback"), "code", "verifier", null, "https://studio.example.attacker"));
var secretResolver = new MutableSecretResolver();
var confidentialClient = new AuthenticationClient("confidential", "Confidential", AuthenticationClientType.Confidential, new HashSet<Uri> { new("https://studio.example/authentication/external/callback") }, new HashSet<Uri>(), new HashSet<string>(), new HashSet<string> { "/workflows" }, new SecretBinding("test", "client"), true);
var confidentialBroker = CreateBroker(new RecordingAdapter(), clients: [confidentialClient], resolvers: [secretResolver]);
var confidentialResult = await confidentialBroker.ExchangeAsync(new BrokerTokenRequest("authorization_code", "confidential", new Uri("https://studio.example/authentication/external/callback"), "code", "verifier", null, null, "other-client", "secret"));
Assert.Equal("invalid_request", publicResult.Error?.Error);
Assert.Equal("invalid_request", confidentialResult.Error?.Error);
}
[Fact]
public async Task SecretGenerationRotationInvalidatesTrustedCallback()
{
var resolver = new MutableSecretResolver();
var adapter = new RecordingAdapter { ThrowOnCallback = true };
var broker = CreateBroker(adapter, resolvers: [resolver], configureConnection: connection => connection.SecretBindings["clientSecret"] = new SecretBinding("test", "client"));
await broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
resolver.Generation = "generation-2";
var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
2026-07-24 16:59:17 +00:00
Assert.Equal("flow_changed", result.Error?.Error);
}
[Fact]
public async Task RejectedBrokerOutcomesPublishTheirSafePublicCategory()
{
var sender = Substitute.For<INotificationSender>();
await using var services = new ServiceCollection().AddSingleton(sender).BuildServiceProvider();
var notifier = new ExternalAuthenticationSecurityNotifier(services);
var broker = CreateBroker(new RecordingAdapter(), notifier: notifier);
var result = await broker.InitiateExternalAsync(Request("//attacker.example"), "tenant-a");
Assert.Equal("invalid_request", result.Error?.Error);
await sender.Received(1).SendAsync(
Arg.Is<ExternalAuthenticationOutcomeRecorded>(notification =>
notification.Flow == "external" &&
notification.Stage == "initiate" &&
notification.Category == "invalid_request" &&
notification.Context.Outcome == SecurityEventOutcome.Rejected),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task AdapterInitiationFailureReturnsASafeObservedOutcome()
{
var sender = Substitute.For<INotificationSender>();
await using var services = new ServiceCollection().AddSingleton(sender).BuildServiceProvider();
var broker = CreateBroker(
new RecordingAdapter { ThrowOnInitiation = true },
notifier: new ExternalAuthenticationSecurityNotifier(services));
var result = await broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
Assert.Equal("temporarily_unavailable", result.Error?.Error);
await sender.Received(1).SendAsync(
Arg.Is<ExternalAuthenticationOutcomeRecorded>(notification =>
notification.Category == "temporarily_unavailable" &&
notification.Context.Outcome == SecurityEventOutcome.Failed),
Arg.Any<CancellationToken>());
}
internal static ExternalAuthenticationBroker CreateBroker(
RecordingAdapter adapter,
IAuthorizationGrantStore? grants = null,
IExternalAuthenticationHandleHasher? hasher = null,
IEnumerable<ISecretBindingResolver>? resolvers = null,
IReadOnlyCollection<AuthenticationClient>? clients = null,
Action<IdentityProviderConnection>? configureConnection = null,
IExternalIdentityResolver? identityResolver = null,
IPermissionGrantResolver? permissionGrantResolver = null,
IExternalAuthenticationSessionStore? sessionStore = null,
IExternalAuthenticationTokenIssuer? tokenIssuer = null,
IUserCredentialsValidator? credentialsValidator = null,
IUserProvider? userProvider = null,
IRoleProvider? roleProvider = null,
IElsaTokenService? tokenService = null,
IIdentityRefreshTokenService? identityRefreshTokenService = null,
ITenantAccessor? tenantAccessor = null,
ExternalAuthenticationSecurityNotifier? notifier = null,
ConnectionValidity connectionValidity = ConnectionValidity.Valid,
ConnectionValidity? assessedValidity = null,
bool includeLoginMethod = false,
ISystemClock? clock = null)
2026-07-24 16:59:17 +00:00
{
var connection = new IdentityProviderConnection
{
Id = "connection-a", TenantId = "tenant-a", Key = "contoso", AdapterType = "fake", AdapterSettings = JsonSerializer.SerializeToElement(new { }),
DisplayName = "Contoso", IsEnabled = true, MaterialRevision = "revision-a"
};
configureConnection?.Invoke(connection);
var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, new(ConnectionScopeKind.Tenant, "tenant-a"), connectionValidity, false, "test");
2026-07-24 16:59:17 +00:00
var registry = Substitute.For<IIdentityProviderConnectionRegistry>();
registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
registry.FindByIdAsync("tenant-a", "connection-a", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
IReadOnlyCollection<LoginMethod> loginMethods = includeLoginMethod
? [new LoginMethod(connection.Id, connection.Key, LoginMethodKind.External, connection.DisplayName, null, 0, false, new Uri($"/external-authentication/authorize/{connection.Key}", UriKind.Relative))]
: [];
registry.GetAsync("tenant-a", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult(new EffectiveConnectionRegistry([effective], loginMethods, "v1")));
var validityAssessor = Substitute.For<IIdentityProviderConnectionValidityAssessor>();
validityAssessor.AssessAsync(Arg.Any<EffectiveIdentityProviderConnection>(), Arg.Any<CancellationToken>())
.Returns(call => ValueTask.FromResult(call.Arg<EffectiveIdentityProviderConnection>() with
{
Validity = assessedValidity ?? call.Arg<EffectiveIdentityProviderConnection>().Validity
}));
2026-07-24 16:59:17 +00:00
var options = Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions
{
Clients = clients?.ToList() ?? [new AuthenticationClient("studio", "Studio", AuthenticationClientType.Public,
new HashSet<Uri> { new("https://studio.example/authentication/external/callback") }, new HashSet<Uri>(), new HashSet<string> { "https://studio.example" }, new HashSet<string> { "/workflows" }, null, true)]
});
var brokerClock = clock ?? new TestClock();
return new ExternalAuthenticationBroker(registry, validityAssessor, [adapter], resolvers ?? [], hasher ?? new HmacExternalAuthenticationHandleHasher(), new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider(), identityResolver ?? Substitute.For<IExternalIdentityResolver>(), permissionGrantResolver ?? Substitute.For<IPermissionGrantResolver>(), new InMemoryExternalAuthenticationStateStore(brokerClock), grants ?? new InMemoryAuthorizationGrantStore(brokerClock), sessionStore ?? new InMemoryExternalAuthenticationSessionStore(brokerClock), tokenIssuer ?? Substitute.For<IExternalAuthenticationTokenIssuer>(), credentialsValidator ?? Substitute.For<IUserCredentialsValidator>(), userProvider ?? Substitute.For<IUserProvider>(), roleProvider ?? Substitute.For<IRoleProvider>(), tokenService ?? Substitute.For<IElsaTokenService>(), identityRefreshTokenService ?? Substitute.For<IIdentityRefreshTokenService>(), tenantAccessor ?? new DefaultTenantAccessor(), brokerClock, options, notifier);
2026-07-24 16:59:17 +00:00
}
private static BrokerAuthorizationRequest Request(string returnPath) => new("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", returnPath, "contoso");
private static string? Query(Uri uri, string key) => System.Web.HttpUtility.ParseQueryString(uri.Query)[key];
private static IdentityLinkTrackingScenario CreateIdentityLinkTrackingScenario(bool throwOnCallback = false)
{
var clock = new MutableClock(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
var identity = new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>());
feat(auth)!: structured authorization model, phases 1-6 (#7980) * feat(auth): add the permission model and evaluator (Phase 1) Additive only. Nothing changes behavior: no endpoint declares against this yet, and no existing enforcement path routes through it. A permission is {resource}:{verb}, both axes open and string-keyed. A trailing wildcard on the resource axis matches the named node and every descendant at any depth, so workflows/definitions/* covers workflows/definitions itself; * on the verb axis matches any verb. Wildcards are the only construct with forward reach. A bare * parses to *:* at parse time rather than being special-cased in the evaluator, so superuser stays an ordinary grant and a stored or seeded * keeps authorizing across the vocabulary migration without a lock-out window. Adds: - Permission, with parsing that rejects a value containing a comma, since the persistence converter joins collections with one - CoreVerbs, the recommended set modules should reuse; a convention rather than a closed vocabulary - PermissionMatcher, one matching rule shape on both axes - IPermissionEvaluator, the single place permission decisions are made, skipping malformed claims so one bad stored grant cannot deny a principal - PermissionRequirement and PermissionAuthorizationHandler - The descriptor catalog in core: PermissionDescriptor now carries the verbs a resource supports and marks non-core ones, and the registry can report what a wildcard covers today External Authentication keeps its own descriptor types for now; it moves to the core catalog with the other modules in Phase 2, which keeps this change purely additive. 55 unit tests cover the matcher table, wildcard forward reach, the counterpart that concrete grants stay frozen, absence-is-denial, and the seeded * case. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): contribute the permission catalog from every module (Phase 2) Still additive. Existing endpoints keep their legacy declarations; nothing changes behavior for them. Every module exposing protected endpoints now declares its resources and the verbs each accepts, following the pattern already proven in External Authentication -- constants and descriptors colocated -- refined to one constant per resource, with the verb supplied separately. 47 resources across 15 modules, matching the settled vocabulary. Descriptors are discovered from the same assemblies as a module's endpoints, in AddFastEndpointsFromModule. Registering them per module would let the catalog and the endpoints drift, which is the failure this model exists to remove; tying them to one registration makes the catalog necessarily describe the endpoints that exist. Adds: - GET /identity/permissions, the catalog a role editor renders from, so no client hard-codes permission strings - GET /identity/permissions/reach, reporting what a wildcard covers today. This is the mitigation for forward reach on the resource axis: a wildcard is useful precisely because it covers things that do not exist yet, so an author needs to see what it reaches now - GET /identity/me/permissions, resolving wildcards to concrete verbs so a client needs no matching logic, and listing denied resources with an empty verb list so "denied" is distinguishable from "unknown" - IPermissionGrantValidator, wired into Roles/Create and Roles/Update, which previously persisted request.Permissions after only the caller-subset check. Concrete segments validate against the catalog; wildcards validate structurally and are accepted even when they match nothing today, since installing a module later is what gives such a grant meaning - RequirePermission(resource, verb) and RequireAuthenticatedOnly() on the endpoint base classes, with the six copy-pasted ConfigurePermissions bodies collapsed into one implementation New endpoints require new-format grants, so during the transition they authorize only for holders of *, which parses to *:*. Phase 3 migrates the rest and closes that gap. 70 unit tests, including the wildcard-accepting validator cases. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth)!: cut every endpoint over to the permission model (Phase 3) BREAKING: legacy permission strings no longer authorize. A permanent alias layer would keep two vocabularies valid forever, so the break is deliberate and reported rather than absorbed. `*` survives unchanged -- it parses to `*:*` -- so an administrator cannot be locked out while roles are re-authored. All 168 declaration call sites across 151 files now use RequirePermission(resource, verb) with the constants their module declares, so a typo is a compile error rather than an unreachable endpoint. Enforcement consolidated onto IPermissionEvaluator: - RoleAuthorizationService evaluates containment through the evaluator rather than by set membership. This matters: a caller holding workflows/*:view can now delegate workflows/definitions:view, which set membership got wrong and which would otherwise force administrators to hold every concrete grant they wish to delegate. - The two Broker/Logout.cs endpoints declare explicitly. Logout is authenticated-only; ContinueLogout is anonymous, matching every other broker callback -- the route handle carries the authority and a top-level browser navigation sends no Authorization header. Removes the C#/Python expression permissions (#7975). They conflated an incoherent execution-side gate -- a workflow runs under the server's authority, not the caller's, so the check never constrained what a script could do -- with a meaningful authoring-side one. The host switch (AllowHostCodeExecution) becomes the single control. This is a deliberate reduction in control: where host code is enabled, any author who may write definitions may use C# and Python. Adds the fail-closed gate. Omitting a declaration previously inherited the FastEndpoints default with no Elsa-level fallback, so an endpoint could ship ungated unnoticed. EndpointCoverage asserts every endpoint declares exactly one of RequirePermission, RequireAuthenticatedOnly or AllowAnonymous, with no exemption list. Its canary assertion earned its keep immediately by catching that the gate was scanning an assembly containing no endpoints. EndpointPermissionRegistry records what each endpoint declares. The requirement is attached as an inline policy and is not readable back from the definition, so this keeps the declaration introspectable -- and lets tests assert a specific requirement rather than merely that one exists. Two behavior notes worth calling out: - The runtime status endpoint previously accepted either the read or the manage permission. It now requires workflows/runtime:view alone, which is least privilege; a role holding only control must also be granted view to read status. - BPMN interchange repeats the workflow-definitions path locally rather than taking a dependency on Elsa.Workflows.Api for one constant. It contributes no descriptor: the resource is owned and described by Workflows.Api, and the registry keeps one entry per resource. Also adds a startup validator that logs every stored role permission that no longer resolves, identified by role, so an upgrade is loud. 188 unit tests pass across Api.Common, Workflows.Api and Identity. Refs #7974, #7975, #7976 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): revocation bound and role audit notifications (Phase 4) Default access-token lifetime drops from 1 hour to 15 minutes. This is the revocation bound: permission claims are issued at sign-in and refresh re-reads the user's roles, so removing a role takes effect at most one access-token lifetime later. Refresh already rotates both tokens, so no client change is required and the refresh lifetime is unchanged. Adds an optional permission stamp for deployments needing a tighter bound. The stamp is derived from the user's roles and their permissions rather than stored as a counter on the user. That avoids changing the Identity schema, which would have required migrations across all five EF providers and made this milestone depend on the tenancy work. It also means every node computes the same value from the same store with no cross-node cache invalidation, which matters because Elsa has none. The stamp is issued unconditionally and only validated when enabled, so turning it on does not invalidate tokens already in flight; an absent stamp is not treated as a mismatch for the same reason. It changes when a role is added to or removed from the user and when a held role's permissions change, but not when an unrelated role changes. Role create and update now publish typed security notifications per ADR 0007, carrying the resulting grants so a reviewer can reconstruct what a role conferred at a point in time without replaying every prior event. This module owns no audit store: a future audit module subscribes and sets its own retention. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(auth): tenancy hardening for identity (Phase 5) Closes the gaps that made "roles are configurable per tenant" untrue however the rest of the stack behaved. Uniqueness becomes per tenant. User.Name, Role.Name, Application.Name and Application.ClientId carried globally unique indexes, so two tenants could not both hold a role named Admin. Migrations for all five EF providers drop the global indexes and create composite ones on (TenantId, Name). The in-memory user and role stores now scope to the ambient tenant. Isolation previously existed only on the Entity Framework path, and only when multitenancy was enabled, so a deployment running the default stores had none at all. The tenant-agnostic sentinel is honored, matching the EF query filter, so a shared platform role stays visible from every tenant. RoleFilter gains TenantId, matching UserFilter, and the role and user list endpoints pass it explicitly rather than relying on an ambient filter that only exists on one persistence path. UserManager.CreateUserAsync sets TenantId explicitly instead of relying on the EF saving handler, which does not run in memory and left users unassigned there. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: migration guide, ADR, and security wiki for the authorization model Adds docs/migrations/authorization-model.md, following the shape of the external-authentication persistence guide. It leads with the three things that are not a simple rename, because each silently produces a wrong result if treated as one: - The migration expands where new sub-resources are finer-grained than what they replace, so a one-for-one substitution narrows roles. - read:* and exec:* become materially more powerful. They are literal claim values today, authorizing twelve of roughly forty read endpoints; their replacements work as the names always implied. Any role holding them needs review by hand, not an automated rewrite. - The C#/Python expression permissions are removed rather than translated, which is a deliberate reduction in control where host code is enabled. It also states plainly that `*` keeps working, and says to do that first, since it is what stops an instance locking itself out mid-migration. ADR 0012 records the model and, more usefully, why a closed verb enumeration was drafted and rejected: it was justified on implication, but aggregates were already excluded and no verb implies another, so the bitwise check was expressing set containment all along. The security wiki's API Authorization section replaces its Secrets-only route table with the catalog endpoint as the authoritative source, and states why read-only mode is a separate axis rather than a permission. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(auth): restore the suites after the tenancy and evaluator changes The whole solution builds with zero errors and every affected suite passes: 70 Api.Common, 23 Workflows.Api, 95 Identity, 154 External Authentication unit, 133 External Authentication integration. Most breakage was test call sites constructing the tenant-aware stores and the evaluator-backed RoleAuthorizationService directly. Adds TestTenantAccessor to Elsa.Testing.Shared rather than giving the production constructors an optional accessor, which would have let a missing registration silently disable isolation. Several External Authentication tests created fixtures in tenant-a while running under the default tenant, so the newly isolating store correctly stopped finding them. They are now scoped to the tenant their own fixtures use; JustInTimeProvisioningTests, which genuinely spans two tenants, is scoped per case. One production fix came out of it: IdentityFeature now ensures an ITenantAccessor with TryAdd. The identity stores are tenant-scoped, so a host that never enables multitenancy would otherwise fail to construct them -- which is what the DI registration tests were reporting. TryAdd leaves MultitenancyFeature's own registration untouched. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): register the identity services on the classic feature path Found by running Elsa.Server.Web, not by the test suites: the app failed at startup with "Unable to resolve service for type RoleSecurityNotifier while attempting to activate Roles.Update". RoleSecurityNotifier, the permission stamp services, the memory cache and the stored-permission validator were registered only in the CShells shell feature. Elsa.Server.Web uses the classic UseIdentity() path, whose IdentityFeature registered none of them, so every host on that path crashed while mapping endpoints. Unit tests did not catch it because they construct services directly rather than through either feature. Verified end to end against the running server: - The seeded admin role stores "*". It parsed to *:* and resolved to concrete verbs across all 27 registered resources, which is the bare-wildcard parse rule working on real data rather than in a test. - GET /identity/permissions returns the catalog for the modules this app installs -- 27 resources, 0 unverified, categories Dashboard, Identity, Resilience and Workflows -- rather than all 47, which is correct: the catalog describes what is installed. - GET /identity/permissions/reach?resource=workflows/* reports 19 covered resources. - A role holding only dashboard:view gets 200 on /dashboard/overview and 403 on /identity/roles, /identity/users, /workflow-definitions and /identity/permissions, while /identity/me/permissions returns 200 because it declares RequireAuthenticatedOnly -- confirming FR-019's third declaration state behaves as designed. - That same principal's /me/permissions lists all 27 resources with 26 carrying an empty verb list, so "denied" stays distinguishable from "unknown to this server". - The startup validator logged no unresolvable permissions, as expected for a seed holding only "*". Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): discover permission descriptors on the shell host path Found by running Elsa.ModularServer.Web. The shell host started cleanly and authorized correctly, but GET /identity/permissions returned zero resources and /identity/me/permissions returned no grants. Descriptor discovery was wired into AddFastEndpointsFromModule, which only the classic module path calls. CShells discovers endpoints from features implementing its own marker interface, so on a shell host no provider was ever registered. Authorization still worked, because the evaluator reads claims and needs no descriptors -- which is exactly why nothing failed loudly. What silently broke was everything built on the catalog: role authoring would have rejected every concrete grant as an unknown resource, introspection returned nothing for clients to render, and the stored-permission validator would have reported every concrete stored permission as unresolvable. ElsaFastEndpointsFeature now contributes descriptors from the loaded Elsa assemblies, bounded to those and run once per shell. Verified on the modular host, which installs far more modules than Elsa.Server.Web: - 47 resources registered, 0 unverified, across all 12 categories, with all 17 module-specific verbs present. That is the entire published vocabulary confirmed against a running server rather than a document. - Reach reports workflows/* covering 20, external-authentication/* covering 8, and * covering 47. - Creating a role with dashboard:view and workflows/*:view succeeds, confirming a wildcard grant survives authoring validation. - Creating one with invented/resource:view and secrets:publish is rejected with 400. Also makes those rejections actionable. The permission was reported without the reason, so an operator learned which entry was wrong but not why; both parts are now in the message, including the supported verbs for the resource. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * wip: bpmn test vocabulary * fix(auth): make enforcement DI-independent and finish the hub cutover CI on #7980 was red. Running the full suite locally rather than the subset I had been checking surfaced 24 failures across four projects, in three distinct classes. Enforcement no longer depends on a DI registration. RequirePermission attached a PermissionRequirement evaluated by a registered handler, so a host that had not called AddElsaAuthorization got 403 on every endpoint with nothing to indicate why. Several test hosts wire FastEndpoints directly and did exactly that. The requirement is now evaluated inline against a shared stateless evaluator, with a host-registered IPermissionEvaluator still taking precedence. Registration remains worthwhile for the catalog and the validator; authorization can no longer silently fail closed because of a missing one. Registration also moved from AddFastEndpointsFromModule to AddFastEndpointsAssembly. Registering an endpoint assembly is what should guarantee its permissions work, and a host may never call the former. Finishes T039. The four SignalR hubs still matched hard-coded legacy permission strings, which no longer exist, so every hub denied access. They now route through the evaluator like every other enforcement path. Test fixtures granting legacy strings were updated to the new vocabulary. Two categories were deliberately left alone: naming tests asserting the legacy constants still hold their old values, which is true and worth keeping, and the workflow script authorization tests, which asserted a MissingPermission outcome that D21 removed -- those now assert the host switch is the only control. One test previously pinned that the hub honors a FastEndpoints-configured permissions claim type. It now asserts the opposite, and says why: Elsa is the only authority that expands roles into permission claims (ADR 0009), and this model no longer uses the FastEndpoints permission mechanism, so its separately configurable claim type is not consulted. That property is also unreadable outside reflection. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(auth): scope the permission-stamp cache to the tenant Greptile found and reproduced a cross-tenant authorization bug, and it was mine: Phase 5 made user names unique per tenant rather than globally, but PermissionStampValidator kept caching by user name alone. Tenant A's lookup could therefore populate the cache with its own stamp and satisfy a revoked token belonging to a same-named user in tenant B, without ever resolving tenant B's user. Both the cache key and the user lookup are now tenant-scoped. Added PermissionStampValidatorTests, including the cross-tenant case; verified it fails without the fix and passes with it. Also from review: - Removed the legacy permission constants left unused in the three hubs after they moved to the evaluator, so no stale vocabulary lingers. - Narrowed two generic catch clauses. The IL scanner now catches only the exceptions an unresolvable metadata token actually throws, and the startup validator rethrows cancellation while still refusing to stop the host for anything else -- an unreachable or half-migrated store is exactly when an operator most needs the host up. Whole solution builds with 0 errors and every test project passes. Refs #7974 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(docs): correct path in log message for authorization model migration link Aligns the log message path to the correct documentation directory, changing `docs` to `doc` to avoid confusion and incorrect linking during log output. * fix(auth): update permissions method to use new syntax * docs: consolidate docs/ into doc/ The repository had two documentation roots. Merge docs/ into doc/ and remove the empty docs/ tree. The two adr/ folders both numbered from 0001, so the identity and authorization series is renumbered to continue the core series rather than collide with it: docs/adr/0001-0012 -> doc/adr/0014-0025 Every reference is updated to match: the Status cross-links between the renumbered ADRs, the ADR and path links in specs/012-external-authentication and specs/013-rbac-authorization-model, and doc/wiki/identity-tenancy-security.md. doc/adr/toc.md gains entries 14-25. doc/adr/graph.dot is regenerated out to 25; it had been stale since ADR 10 and now also carries the partial supersession edges declared by the ADRs themselves. docs/codebase/ and docs/migrations/ move across unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(auth): simplify syntax in PermissionEvaluator and related classes Streamlined syntax for method definitions by using expression-bodied members and simplified object instantiations across the Authorization module. This includes adjustments in `PermissionEvaluator`, `LocalHostRequirement`, and `WebApplicationExtensions` for better readability and maintainability. * ci(bounty): point the footer step at the file's real path The bounty workflow read docs/bounty-footer.md, the path the file had when the workflow was added in b421b00e1. The file later moved to doc/bounty/bounty-footer.md and the workflow was never updated, so the read step has been resolving nothing and the appended comment was empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:44:55 +00:00
var users = new MemoryUserStore(new MemoryStore<User>(), new TestTenantAccessor("tenant-a"));
var provisioner = new InMemoryExternalIdentityProvisioner(
users,
new StoreBasedUserProvider(users),
Substitute.For<IRoleProvider>(),
new GuidIdentityGenerator(),
clock,
new FixedHasher(),
new InMemoryExternalIdentityProvisionerState());
var identityResolver = new DefaultExternalIdentityResolver(
provisioner,
[new CreateUserUnlinkedIdentityPolicy()],
Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions
{
UnlinkedIdentityPolicy = new UnlinkedIdentityPolicyOptions { DefaultType = "create-user" }
}));
var permissionGrantResolver = Substitute.For<IPermissionGrantResolver>();
permissionGrantResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new PermissionGrantResult([], [])));
var adapter = new RecordingAdapter
{
ThrowOnCallback = throwOnCallback,
AuthenticationResult = new ExternalAuthenticationResult(identity, identity.Claims, [])
};
var broker = CreateBroker(adapter, identityResolver: identityResolver, permissionGrantResolver: permissionGrantResolver, clock: clock);
return new IdentityLinkTrackingScenario(broker, provisioner, adapter, identity, clock);
}
private static async Task<BrokerCallbackResult> CompleteExternalSignInAsync(IdentityLinkTrackingScenario scenario)
{
await scenario.Broker.InitiateExternalAsync(Request("/workflows"), "tenant-a");
return await scenario.Broker.CompleteCallbackAsync("contoso", scenario.Adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [scenario.Adapter.CorrelationState!] });
}
2026-07-24 16:59:17 +00:00
private sealed class TestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-01-01T00:00:00Z"); }
private sealed class CurrentTestClock : ISystemClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }
private sealed class MutableClock(DateTimeOffset utcNow) : ISystemClock { public DateTimeOffset UtcNow { get; set; } = utcNow; }
private sealed record IdentityLinkTrackingScenario(
ExternalAuthenticationBroker Broker,
InMemoryExternalIdentityProvisioner Provisioner,
RecordingAdapter Adapter,
ExternalIdentity Identity,
MutableClock Clock);
2026-07-24 16:59:17 +00:00
internal sealed class RecordingAdapter : IExternalAuthenticationAdapter
{
public string Type => "fake";
public string? CorrelationState { get; private set; }
public BrokerTransaction? Transaction { get; private set; }
public EffectiveIdentityProviderConnection? Connection { get; private set; }
public bool ThrowOnInitiation { get; init; }
public bool ThrowOnCallback { get; init; }
public ExternalAuthenticationResult? AuthenticationResult { get; init; }
public ExternalAuthenticationAdapterDescriptor Describe() => throw new NotSupportedException();
public ValueTask<ConnectionValidationResult> ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<ExternalAuthorizationRequest> CreateAuthorizationRequestAsync(ExternalAuthorizationContext context, CancellationToken cancellationToken = default)
{
if (ThrowOnInitiation)
throw new InvalidOperationException();
CorrelationState = context.CorrelationState;
Transaction = context.Transaction;
Connection = context.Connection;
return ValueTask.FromResult(new ExternalAuthorizationRequest(new Uri($"https://issuer.example/authorize?state={Uri.EscapeDataString(context.CorrelationState)}"), [1, 2, 3]));
}
public ValueTask<ExternalAuthenticationResult> AuthenticateCallbackAsync(ExternalCallbackContext context, CancellationToken cancellationToken = default)
{
if (ThrowOnCallback)
throw new InvalidOperationException();
if (AuthenticationResult is not null)
return ValueTask.FromResult(AuthenticationResult);
throw new NotSupportedException();
}
public ValueTask<ConnectionTestResult> TestAsync(ConnectionTestContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public ValueTask<ExternalLogoutRequest?> CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
private sealed class FixedHasher : IExternalAuthenticationHandleHasher
{
public string Hash(string value) => $"hash:{value}";
}
private sealed class MutableSecretResolver : ISecretBindingResolver
{
public string Type => "test";
public string Generation { get; set; } = "generation-1";
public ValueTask<SecretBindingState> GetStateAsync(SecretBinding binding, CancellationToken cancellationToken = default) => ValueTask.FromResult(new SecretBindingState(true, true));
public ValueTask<ResolvedSecretBinding> ResolveAsync(SecretBinding binding, CancellationToken cancellationToken = default) => ValueTask.FromResult(new ResolvedSecretBinding(new SensitiveString("secret"), Generation));
}
}