Skip to content

Commit e773fa9

Browse files
committed
Added PKCE Tests
1 parent 172a523 commit e773fa9

9 files changed

Lines changed: 346 additions & 12 deletions

File tree

‎src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs‎

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using CodeBeam.UltimateAuth.Core.Contracts;
33
using CodeBeam.UltimateAuth.Core.Defaults;
44
using CodeBeam.UltimateAuth.Core.Domain;
5+
using CodeBeam.UltimateAuth.Core.Errors;
56
using CodeBeam.UltimateAuth.Server.Abstractions;
67
using CodeBeam.UltimateAuth.Server.Auth;
78
using CodeBeam.UltimateAuth.Server.Extensions;
@@ -208,11 +209,17 @@ public async Task<IResult> CompleteAsync(HttpContext ctx)
208209
{
209210
var form = await ctx.GetCachedFormAsync();
210211

211-
var codeChallenge = form["code_challenge"].ToString();
212-
var challengeMethod = form["challenge_method"].ToString();
213-
var redirectUri = form["redirect_uri"].ToString();
212+
var codeChallenge = form?["code_challenge"].ToString();
213+
var challengeMethod = form?["challenge_method"].ToString();
214+
var redirectUri = form?["redirect_uri"].ToString();
214215

215-
var deviceRaw = form["device"].FirstOrDefault();
216+
if (string.IsNullOrWhiteSpace(codeChallenge))
217+
throw new UAuthValidationException("code_challenge is required");
218+
219+
if (string.IsNullOrWhiteSpace(challengeMethod))
220+
throw new UAuthValidationException("challange_method is required");
221+
222+
var deviceRaw = form?["device"].FirstOrDefault();
216223
DeviceContext? device = null;
217224

218225
if (!string.IsNullOrWhiteSpace(deviceRaw))
@@ -258,19 +265,25 @@ public async Task<IResult> CompleteAsync(HttpContext ctx)
258265
{
259266
var form = await ctx.GetCachedFormAsync();
260267

261-
var authorizationCode = form["authorization_code"].FirstOrDefault();
262-
var codeVerifier = form["code_verifier"].FirstOrDefault();
263-
var identifier = form["Identifier"].FirstOrDefault();
264-
var secret = form["Secret"].FirstOrDefault();
265-
var returnUrl = form["return_url"].FirstOrDefault();
268+
var authorizationCode = form?["authorization_code"].FirstOrDefault();
269+
var codeVerifier = form?["code_verifier"].FirstOrDefault();
270+
var identifier = form?["Identifier"].FirstOrDefault();
271+
var secret = form?["Secret"].FirstOrDefault();
272+
var returnUrl = form?["return_url"].FirstOrDefault();
273+
274+
if (string.IsNullOrWhiteSpace(authorizationCode))
275+
throw new UAuthValidationException("authorization_code is required");
276+
277+
if (string.IsNullOrWhiteSpace(codeVerifier))
278+
throw new UAuthValidationException("code_verifier is required");
266279

267280
return new PkceCompleteRequest
268281
{
269282
AuthorizationCode = authorizationCode,
270283
CodeVerifier = codeVerifier,
271-
Identifier = identifier,
272-
Secret = secret,
273-
ReturnUrl = returnUrl
284+
Identifier = identifier ?? string.Empty,
285+
Secret = secret ?? string.Empty,
286+
ReturnUrl = returnUrl ?? string.Empty
274287
};
275288
}
276289

‎src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ public async Task<PkceCompleteResult> CompleteAsync(AuthFlowContext auth, PkceCo
124124

125125
public async Task<HubCredentials> RefreshAsync(HubFlowArtifact hub, CancellationToken ct = default)
126126
{
127+
if (hub.Payload.TryGet<string>("authorization_code", out var oldCode) && !string.IsNullOrWhiteSpace(oldCode))
128+
{
129+
await _authStore.ConsumeAsync(new AuthArtifactKey(oldCode), ct);
130+
}
131+
127132
var verifier = CreateVerifier();
128133
var challenge = CreateChallenge(verifier);
129134
var device = hub.Device;

‎src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ public async Task<TryLoginResult> TryLoginAsync(LoginRequest request, UAuthSubmi
6666
{
6767
var result = await _post.SendJsonAsync(tryUrl, request);
6868

69+
if (result.Body is null)
70+
throw new UAuthProtocolException("Empty response body.");
71+
6972
var parsed = result.Body.Value.Deserialize<TryLoginResult>(
7073
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
7174

@@ -278,6 +281,9 @@ public async Task<TryPkceLoginResult> TryCompletePkceLoginAsync(PkceCompleteRequ
278281
{
279282
var raw = await _post.SendJsonAsync(tryUrl, request);
280283

284+
if (raw.Body is null)
285+
throw new UAuthProtocolException("Empty response body.");
286+
281287
var parsed = raw.Body.Value.Deserialize<TryPkceLoginResult>(
282288
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
283289

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers;
55
internal static class TestDevice
66
{
77
public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null);
8+
public static DeviceContext Alternative() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-alternative"), null, null, null, null, null);
89
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using CodeBeam.UltimateAuth.Core.Domain;
2+
using CodeBeam.UltimateAuth.Server.Flows;
3+
4+
namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers;
5+
6+
internal static class TestHubFactory
7+
{
8+
public static HubFlowArtifact Create(PkceAuthorizationArtifact pkce)
9+
{
10+
var payload = new HubFlowPayload();
11+
payload.Set("authorization_code", pkce.AuthorizationCode.Value);
12+
payload.Set("code_verifier", "test");
13+
14+
return new HubFlowArtifact(
15+
HubSessionId.New(),
16+
HubFlowType.Login,
17+
pkce.Context.ClientProfile,
18+
pkce.Context.Tenant,
19+
TestDevice.Default(),
20+
"/",
21+
payload,
22+
DateTimeOffset.UtcNow.AddMinutes(5)
23+
);
24+
}
25+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using CodeBeam.UltimateAuth.Core.Infrastructure;
2+
using CodeBeam.UltimateAuth.Core.MultiTenancy;
3+
using CodeBeam.UltimateAuth.Core.Options;
4+
using CodeBeam.UltimateAuth.Server.Flows;
5+
using CodeBeam.UltimateAuth.Server.Stores;
6+
using System.Security.Cryptography;
7+
using System.Text;
8+
9+
namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers;
10+
11+
internal static class TestPkceFactory
12+
{
13+
public static (PkceAuthorizationArtifact Artifact, string Verifier) Create(
14+
UAuthClientProfile profile = UAuthClientProfile.BlazorWasm,
15+
TenantKey? tenant = null)
16+
{
17+
tenant ??= TenantKeys.Single;
18+
19+
var verifier = "test_verifier_123";
20+
21+
using var sha256 = SHA256.Create();
22+
var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier));
23+
var challenge = Base64Url.Encode(hash);
24+
25+
var key = AuthArtifactKey.New();
26+
27+
var snapshot = new PkceContextSnapshot(
28+
clientProfile: profile,
29+
tenant: (TenantKey)tenant,
30+
redirectUri: "/",
31+
device: TestDevice.Default()
32+
);
33+
34+
var artifact = new PkceAuthorizationArtifact(
35+
authorizationCode: key,
36+
codeChallenge: challenge,
37+
challengeMethod: PkceChallengeMethod.S256,
38+
expiresAt: DateTimeOffset.UtcNow.AddMinutes(5),
39+
context: snapshot
40+
);
41+
42+
return (artifact, verifier);
43+
}
44+
}

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
using CodeBeam.UltimateAuth.Core.Domain;
55
using CodeBeam.UltimateAuth.Core.Events;
66
using CodeBeam.UltimateAuth.Core.MultiTenancy;
7+
using CodeBeam.UltimateAuth.Core.Options;
8+
using CodeBeam.UltimateAuth.Server.Auth;
9+
using CodeBeam.UltimateAuth.Server.Flows;
10+
using CodeBeam.UltimateAuth.Server.Services;
711
using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
812
using FluentAssertions;
913
using Microsoft.Extensions.DependencyInjection;
@@ -357,4 +361,75 @@ public async Task Event_handler_exception_should_not_break_login_flow()
357361

358362
result.IsSuccess.Should().BeTrue();
359363
}
364+
365+
[Fact]
366+
public async Task Login_With_Execution_Context_Should_Create_Session_For_Overridden_Device()
367+
{
368+
var runtime = new TestAuthRuntime<string>();
369+
370+
using var scope = runtime.Services.CreateScope();
371+
372+
var flowService = scope.ServiceProvider.GetRequiredService<IUAuthFlowService>();
373+
var sessionStoreFactory = scope.ServiceProvider.GetRequiredService<ISessionStoreFactory>();
374+
375+
var flow = await runtime.CreateLoginFlowAsync();
376+
var overriddenDevice = TestDevice.Alternative();
377+
378+
var execution = new AuthExecutionContext
379+
{
380+
EffectiveClientProfile = UAuthClientProfile.BlazorWasm,
381+
Device = overriddenDevice
382+
};
383+
384+
var result = await flowService.LoginAsync(
385+
flow,
386+
execution,
387+
new LoginRequest
388+
{
389+
Identifier = "user",
390+
Secret = "user"
391+
}, CancellationToken.None);
392+
393+
result.IsSuccess.Should().BeTrue();
394+
result.SessionId.Should().NotBeNull();
395+
396+
var sessionStore = sessionStoreFactory.Create(TenantKeys.Single);
397+
var session = await sessionStore.GetSessionAsync(result.SessionId!.Value);
398+
399+
session.Should().NotBeNull();
400+
session!.Device.DeviceId.Should().Be(overriddenDevice.DeviceId);
401+
402+
var chainStore = sessionStoreFactory.Create(TenantKeys.Single);
403+
var chain = await chainStore.GetChainByDeviceAsync(TestUsers.User, (DeviceId)overriddenDevice.DeviceId!);
404+
405+
chain.Should().NotBeNull();
406+
chain!.Device.DeviceId.Should().Be(overriddenDevice.DeviceId);
407+
}
408+
409+
[Fact]
410+
public async Task Internal_Login_Should_Respect_LoginExecutionOptions()
411+
{
412+
var runtime = new TestAuthRuntime<string>();
413+
414+
using var scope = runtime.Services.CreateScope();
415+
416+
var service = scope.ServiceProvider.GetRequiredService<IUAuthInternalFlowService>();
417+
var flow = await runtime.CreateLoginFlowAsync();
418+
419+
var options = new LoginExecutionOptions
420+
{
421+
422+
};
423+
424+
var result = await service.LoginAsync(
425+
flow,
426+
new LoginRequest
427+
{
428+
Identifier = "user",
429+
Secret = "user"
430+
},
431+
options);
432+
433+
result.IsSuccess.Should().BeTrue();
434+
}
360435
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using CodeBeam.UltimateAuth.Server.Flows;
2+
using CodeBeam.UltimateAuth.Server.Services;
3+
using CodeBeam.UltimateAuth.Server.Stores;
4+
using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
5+
using FluentAssertions;
6+
using Microsoft.Extensions.DependencyInjection;
7+
8+
namespace CodeBeam.UltimateAuth.Tests.Unit;
9+
10+
public class PkceTests
11+
{
12+
[Fact]
13+
public void Pkce_Should_Succeed_With_Valid_Data()
14+
{
15+
var validator = new PkceAuthorizationValidator();
16+
var (artifact, verifier) = TestPkceFactory.Create();
17+
var result = validator.Validate(artifact, verifier, artifact.Context, DateTimeOffset.UtcNow);
18+
19+
result.Success.Should().BeTrue();
20+
}
21+
22+
[Fact]
23+
public void Pkce_Should_Fail_With_Invalid_Verifier()
24+
{
25+
var validator = new PkceAuthorizationValidator();
26+
var (artifact, _) = TestPkceFactory.Create();
27+
var result = validator.Validate(artifact, "wrong_verifier", artifact.Context, DateTimeOffset.UtcNow);
28+
29+
result.Success.Should().BeFalse();
30+
result.FailureReason.Should().Be(PkceValidationFailureReason.InvalidVerifier);
31+
}
32+
33+
[Fact]
34+
public void Pkce_Should_Fail_On_Device_Mismatch()
35+
{
36+
var validator = new PkceAuthorizationValidator();
37+
var (artifact, verifier) = TestPkceFactory.Create();
38+
39+
var wrongContext = new PkceContextSnapshot(
40+
artifact.Context.ClientProfile,
41+
artifact.Context.Tenant,
42+
artifact.Context.RedirectUri,
43+
device: TestDevice.Alternative()
44+
);
45+
46+
var result = validator.Validate(artifact, verifier, wrongContext, DateTimeOffset.UtcNow);
47+
48+
result.Success.Should().BeFalse();
49+
result.FailureReason.Should().Be(PkceValidationFailureReason.ContextMismatch);
50+
}
51+
52+
[Fact]
53+
public async Task Refresh_Should_Generate_New_AuthorizationCode()
54+
{
55+
var runtime = new TestAuthRuntime<string>();
56+
57+
using var scope = runtime.Services.CreateScope();
58+
59+
var store = scope.ServiceProvider.GetRequiredService<IAuthStore>();
60+
var pkceService = scope.ServiceProvider.GetRequiredService<IPkceService>();
61+
62+
var (artifact, _) = TestPkceFactory.Create();
63+
64+
await store.StoreAsync(artifact.AuthorizationCode, artifact);
65+
66+
var hub = TestHubFactory.Create(artifact);
67+
68+
var refreshed = await pkceService.RefreshAsync(hub);
69+
70+
refreshed.AuthorizationCode.Should().NotBe(artifact.AuthorizationCode.Value);
71+
}
72+
73+
[Fact]
74+
public async Task Refresh_Should_Store_New_PkceArtifact()
75+
{
76+
var runtime = new TestAuthRuntime<string>();
77+
78+
using var scope = runtime.Services.CreateScope();
79+
80+
var store = scope.ServiceProvider.GetRequiredService<IAuthStore>();
81+
var pkceService = scope.ServiceProvider.GetRequiredService<IPkceService>();
82+
83+
var (artifact, _) = TestPkceFactory.Create();
84+
85+
await store.StoreAsync(artifact.AuthorizationCode, artifact);
86+
87+
var hub = TestHubFactory.Create(artifact);
88+
89+
var refreshed = await pkceService.RefreshAsync(hub);
90+
91+
var stored = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode));
92+
93+
stored.Should().NotBeNull();
94+
}
95+
96+
[Fact]
97+
public async Task Refresh_Should_Invalidate_Old_Code()
98+
{
99+
var runtime = new TestAuthRuntime<string>();
100+
using var scope = runtime.Services.CreateScope();
101+
var store = scope.ServiceProvider.GetRequiredService<IAuthStore>();
102+
var pkceService = scope.ServiceProvider.GetRequiredService<IPkceService>();
103+
104+
var (artifact, _) = TestPkceFactory.Create();
105+
await store.StoreAsync(artifact.AuthorizationCode, artifact);
106+
var hub = TestHubFactory.Create(artifact);
107+
var refreshed = await pkceService.RefreshAsync(hub);
108+
var old = await store.GetAsync(artifact.AuthorizationCode);
109+
110+
old.Should().BeNull();
111+
}
112+
113+
[Fact]
114+
public async Task Refresh_Should_Preserve_Context()
115+
{
116+
var runtime = new TestAuthRuntime<string>();
117+
using var scope = runtime.Services.CreateScope();
118+
var pkceService = scope.ServiceProvider.GetRequiredService<IPkceService>();
119+
120+
var (artifact, _) = TestPkceFactory.Create();
121+
var hub = TestHubFactory.Create(artifact);
122+
var refreshed = await pkceService.RefreshAsync(hub);
123+
124+
var store = scope.ServiceProvider.GetRequiredService<IAuthStore>();
125+
var newArtifact = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode)) as PkceAuthorizationArtifact;
126+
127+
newArtifact!.Context.Device.Should().BeEquivalentTo(artifact.Context.Device);
128+
}
129+
}

0 commit comments

Comments
 (0)