Add test for callback session persistence; extend authentication broker for refresh token hash initialization; enable config connection overrides

This commit is contained in:
Sipke Schoorstra 2026-07-29 13:59:24 +02:00
parent 22bc531aa8
commit 1ccf5b7611
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
3 changed files with 35 additions and 1 deletions

View file

@ -59,6 +59,7 @@
"EncryptionKey": "Q0hBTkdFX01FX1RPX0FfU0VDVVJFX1JBTkRPTV9LRVk="
},
"ExternalAuthentication": {
"AllowConfigurationConnectionOverrides": true,
"LocalLogin": {
"IsEnabled": true
},

View file

@ -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));
/// <summary>
/// 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.
/// </summary>
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)

View file

@ -241,6 +241,31 @@ public sealed class ExternalAuthenticationPersistenceTests : IAsyncLifetime
}
}
[Fact]
public async Task CallbackCompletionPersistsTheSessionBeforeAnyRefreshTokenIsIssued()
{
var identityResolver = Substitute.For<IExternalIdentityResolver>();
identityResolver.ResolveAsync(Arg.Any<ExternalIdentityResolutionContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false)));
var permissionGrantResolver = Substitute.For<IPermissionGrantResolver>();
permissionGrantResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
.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<IServiceScopeFactory>()), _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<string, IReadOnlyCollection<string>> { ["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<string, IReadOnlyCollection<string>> EmptyClaims { get; } = new Dictionary<string, IReadOnlyCollection<string>>();
private static IdentityProviderConnection CreateConnection(string id = "connection-a") => new()