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()