diff --git a/src/apps/Elsa.ModularServer.Web/appsettings.json b/src/apps/Elsa.ModularServer.Web/appsettings.json index 858eb575e..35650f6d8 100644 --- a/src/apps/Elsa.ModularServer.Web/appsettings.json +++ b/src/apps/Elsa.ModularServer.Web/appsettings.json @@ -59,6 +59,7 @@ "EncryptionKey": "Q0hBTkdFX01FX1RPX0FfU0VDVVJFX1JBTkRPTV9LRVk=" }, "ExternalAuthentication": { + "AllowConfigurationConnectionOverrides": true, "LocalLogin": { "IsEnabled": true }, diff --git a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs index 24b8b0686..d0ff405b1 100644 --- a/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs +++ b/src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs @@ -214,7 +214,8 @@ public sealed class ExternalAuthenticationBroker( StartedAt = clock.UtcNow, LastRefreshedAt = clock.UtcNow, ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge), - RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge) + RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge), + CurrentRefreshTokenHash = CreateUnissuedRefreshTokenHash() }; await sessionStore.SaveAsync(session, cancellationToken); var code = CreateOpaqueValue(); @@ -511,6 +512,13 @@ public sealed class ExternalAuthenticationBroker( } private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string.Equals(challenge, Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), StringComparison.Ordinal); private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32)); + + /// + /// Sessions are persisted at callback completion, before the token issuer mints the first refresh token. The column is + /// required and uniquely indexed, so a per-session placeholder is stored until issuance rotates the real hash in. The + /// prefix keeps the value outside the hex-encoded hash space, so it can never be matched by a refresh-token lookup. + /// + private static string CreateUnissuedRefreshTokenHash() => $"unissued:{CreateOpaqueValue()}"; private string Hash(string value) => handleHasher.Hash(value); private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState) diff --git a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs index 1554e7f78..ee82058f7 100644 --- a/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs +++ b/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs @@ -241,6 +241,31 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime } } + [Fact] + public async Task CallbackCompletionPersistsTheSessionBeforeAnyRefreshTokenIsIssued() + { + var identityResolver = Substitute.For(); + identityResolver.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false))); + var permissionGrantResolver = Substitute.For(); + permissionGrantResolver.ResolveAsync(Arg.Any(), Arg.Any()) + .Returns(ValueTask.FromResult(new PermissionGrantResult([], []))); + var adapter = new Broker.BrokerSecurityTests.RecordingAdapter + { + AuthenticationResult = new ExternalAuthenticationResult(new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims), EmptyClaims, []) + }; + var broker = Broker.BrokerSecurityTests.CreateBroker(adapter, identityResolver: identityResolver, permissionGrantResolver: permissionGrantResolver, sessionStore: new EFCoreExternalAuthenticationSessionStore(new ExternalAuthenticationDbContextFactory(_services.GetRequiredService()), _clock)); + await broker.InitiateExternalAsync(new BrokerAuthorizationRequest("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", "/workflows", "contoso"), "tenant-a"); + + var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary> { ["state"] = [adapter.CorrelationState!] }); + + Assert.Null(result.Error); + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + var session = Assert.Single(await dbContext.ExternalAuthenticationSessions.ToListAsync()); + Assert.False(string.IsNullOrEmpty(session.CurrentRefreshTokenHash)); + Assert.Equal("user-a", session.UserId); + } + private static IReadOnlyDictionary> EmptyClaims { get; } = new Dictionary>(); private static IdentityProviderConnection CreateConnection(string id = "connection-a") => new()