Skip to content

Commit 1ccf5b7

Browse files
committed
Add test for callback session persistence; extend authentication broker for refresh token hash initialization; enable config connection overrides
1 parent 22bc531 commit 1ccf5b7

3 files changed

Lines changed: 35 additions & 1 deletion

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"EncryptionKey": "Q0hBTkdFX01FX1RPX0FfU0VDVVJFX1JBTkRPTV9LRVk="
6060
},
6161
"ExternalAuthentication": {
62+
"AllowConfigurationConnectionOverrides": true,
6263
"LocalLogin": {
6364
"IsEnabled": true
6465
},

src/modules/Elsa.ExternalAuthentication/Services/ExternalAuthenticationBroker.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ public async ValueTask<BrokerCallbackResult> CompleteCallbackAsync(string connec
214214
StartedAt = clock.UtcNow,
215215
LastRefreshedAt = clock.UtcNow,
216216
ExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
217-
RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge)
217+
RefreshExpiresAt = clock.UtcNow.Add(options.Value.Lifetimes.MaximumSessionAge),
218+
CurrentRefreshTokenHash = CreateUnissuedRefreshTokenHash()
218219
};
219220
await sessionStore.SaveAsync(session, cancellationToken);
220221
var code = CreateOpaqueValue();
@@ -511,6 +512,13 @@ private static void ValidateAuthorizationRequest(BrokerAuthorizationRequest requ
511512
}
512513
private static bool VerifyPkce(string challenge, string? verifier) => !string.IsNullOrWhiteSpace(verifier) && string.Equals(challenge, Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))), StringComparison.Ordinal);
513514
private static string CreateOpaqueValue() => Base64Url(RandomNumberGenerator.GetBytes(32));
515+
516+
/// <summary>
517+
/// Sessions are persisted at callback completion, before the token issuer mints the first refresh token. The column is
518+
/// required and uniquely indexed, so a per-session placeholder is stored until issuance rotates the real hash in. The
519+
/// prefix keeps the value outside the hex-encoded hash space, so it can never be matched by a refresh-token lookup.
520+
/// </summary>
521+
private static string CreateUnissuedRefreshTokenHash() => $"unissued:{CreateOpaqueValue()}";
514522
private string Hash(string value) => handleHasher.Hash(value);
515523
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
516524
private static Uri AppendCallbackParameters(Uri uri, string code, string? clientState)

test/integration/Elsa.ExternalAuthentication.IntegrationTests/Persistence/ExternalAuthenticationPersistenceTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,31 @@ public async Task DurableConcurrentReplacementUsesTheOldLinkIdAsAnAtomicGuard()
241241
}
242242
}
243243

244+
[Fact]
245+
public async Task CallbackCompletionPersistsTheSessionBeforeAnyRefreshTokenIsIssued()
246+
{
247+
var identityResolver = Substitute.For<IExternalIdentityResolver>();
248+
identityResolver.ResolveAsync(Arg.Any<ExternalIdentityResolutionContext>(), Arg.Any<CancellationToken>())
249+
.Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false)));
250+
var permissionGrantResolver = Substitute.For<IPermissionGrantResolver>();
251+
permissionGrantResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
252+
.Returns(ValueTask.FromResult(new PermissionGrantResult([], [])));
253+
var adapter = new Broker.BrokerSecurityTests.RecordingAdapter
254+
{
255+
AuthenticationResult = new ExternalAuthenticationResult(new ExternalIdentity("https://issuer.example", "subject-a", EmptyClaims), EmptyClaims, [])
256+
};
257+
var broker = Broker.BrokerSecurityTests.CreateBroker(adapter, identityResolver: identityResolver, permissionGrantResolver: permissionGrantResolver, sessionStore: new EFCoreExternalAuthenticationSessionStore(new ExternalAuthenticationDbContextFactory(_services.GetRequiredService<IServiceScopeFactory>()), _clock));
258+
await broker.InitiateExternalAsync(new BrokerAuthorizationRequest("studio", new Uri("https://studio.example/authentication/external/callback"), "code", "challenge", "S256", "/workflows", "contoso"), "tenant-a");
259+
260+
var result = await broker.CompleteCallbackAsync("contoso", adapter.CorrelationState!, new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
261+
262+
Assert.Null(result.Error);
263+
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
264+
var session = Assert.Single(await dbContext.ExternalAuthenticationSessions.ToListAsync());
265+
Assert.False(string.IsNullOrEmpty(session.CurrentRefreshTokenHash));
266+
Assert.Equal("user-a", session.UserId);
267+
}
268+
244269
private static IReadOnlyDictionary<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
245270

246271
private static IdentityProviderConnection CreateConnection(string id = "connection-a") => new()

0 commit comments

Comments
 (0)