Skip to content

Commit 0787734

Browse files
Add gatekeeper
1 parent cc5a50d commit 0787734

70 files changed

Lines changed: 6765 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
namespace Gatekeeper.Api.Tests;
2+
3+
/// <summary>
4+
/// Integration tests for Gatekeeper authentication endpoints.
5+
/// Tests WebAuthn/FIDO2 passkey registration and login flows.
6+
/// </summary>
7+
public sealed class AuthenticationTests : IClassFixture<GatekeeperTestFixture>
8+
{
9+
private readonly HttpClient _client;
10+
11+
public AuthenticationTests(GatekeeperTestFixture fixture)
12+
{
13+
_client = fixture.CreateClient();
14+
}
15+
16+
[Fact]
17+
public async Task RegisterBegin_WithValidEmail_ReturnsChallenge()
18+
{
19+
var request = new { Email = "test@example.com", DisplayName = "Test User" };
20+
21+
var response = await _client.PostAsJsonAsync("/auth/register/begin", request);
22+
23+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
24+
25+
var content = await response.Content.ReadAsStringAsync();
26+
var doc = JsonDocument.Parse(content);
27+
28+
Assert.True(doc.RootElement.TryGetProperty("ChallengeId", out var challengeId));
29+
Assert.False(string.IsNullOrEmpty(challengeId.GetString()));
30+
31+
// API returns OptionsJson as a JSON string (for JS to parse)
32+
Assert.True(doc.RootElement.TryGetProperty("OptionsJson", out var optionsJson));
33+
var parsedOptions = JsonDocument.Parse(optionsJson.GetString()!);
34+
Assert.True(parsedOptions.RootElement.TryGetProperty("challenge", out _));
35+
}
36+
37+
[Fact]
38+
public async Task RegisterBegin_RequiresResidentKey_ForDiscoverableCredentials()
39+
{
40+
// Registration must require resident keys so login works without email
41+
var request = new { Email = "resident@example.com", DisplayName = "Resident User" };
42+
43+
var response = await _client.PostAsJsonAsync("/auth/register/begin", request);
44+
45+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
46+
47+
var content = await response.Content.ReadAsStringAsync();
48+
var doc = JsonDocument.Parse(content);
49+
var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!;
50+
var options = JsonDocument.Parse(optionsJson);
51+
52+
// Verify authenticatorSelection requires resident key
53+
Assert.True(
54+
options.RootElement.TryGetProperty("authenticatorSelection", out var authSelection)
55+
);
56+
Assert.True(authSelection.TryGetProperty("residentKey", out var residentKey));
57+
Assert.Equal("required", residentKey.GetString());
58+
}
59+
60+
[Fact]
61+
public async Task RegisterBegin_RequiresUserVerification()
62+
{
63+
// Registration must require user verification for security
64+
var request = new { Email = "verify@example.com", DisplayName = "Verify User" };
65+
66+
var response = await _client.PostAsJsonAsync("/auth/register/begin", request);
67+
68+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
69+
70+
var content = await response.Content.ReadAsStringAsync();
71+
var doc = JsonDocument.Parse(content);
72+
var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!;
73+
var options = JsonDocument.Parse(optionsJson);
74+
75+
var authSelection = options.RootElement.GetProperty("authenticatorSelection");
76+
Assert.True(authSelection.TryGetProperty("userVerification", out var userVerification));
77+
Assert.Equal("required", userVerification.GetString());
78+
}
79+
80+
[Fact]
81+
public async Task LoginBegin_WithEmptyBody_ReturnsChallenge_ForDiscoverableCredentials()
82+
{
83+
// Discoverable credentials flow: no email needed, browser shows all passkeys
84+
// Server returns challenge with empty allowCredentials
85+
var response = await _client.PostAsJsonAsync("/auth/login/begin", new { });
86+
87+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
88+
89+
var content = await response.Content.ReadAsStringAsync();
90+
var doc = JsonDocument.Parse(content);
91+
92+
// Should return a valid challenge
93+
Assert.True(doc.RootElement.TryGetProperty("ChallengeId", out var challengeId));
94+
Assert.False(string.IsNullOrEmpty(challengeId.GetString()));
95+
96+
// Verify options structure
97+
Assert.True(doc.RootElement.TryGetProperty("OptionsJson", out var optionsJson));
98+
var options = JsonDocument.Parse(optionsJson.GetString()!);
99+
Assert.True(options.RootElement.TryGetProperty("challenge", out _));
100+
101+
// allowCredentials should be empty for discoverable credentials
102+
Assert.True(
103+
options.RootElement.TryGetProperty("allowCredentials", out var allowCredentials)
104+
);
105+
Assert.Equal(JsonValueKind.Array, allowCredentials.ValueKind);
106+
Assert.Equal(0, allowCredentials.GetArrayLength());
107+
}
108+
109+
[Fact]
110+
public async Task LoginBegin_RequiresUserVerification()
111+
{
112+
// Login must require user verification (Touch ID, Face ID, etc.)
113+
var response = await _client.PostAsJsonAsync("/auth/login/begin", new { });
114+
115+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
116+
117+
var content = await response.Content.ReadAsStringAsync();
118+
var doc = JsonDocument.Parse(content);
119+
var optionsJson = doc.RootElement.GetProperty("OptionsJson").GetString()!;
120+
var options = JsonDocument.Parse(optionsJson);
121+
122+
Assert.True(
123+
options.RootElement.TryGetProperty("userVerification", out var userVerification)
124+
);
125+
Assert.Equal("required", userVerification.GetString());
126+
}
127+
128+
[Fact]
129+
public async Task LoginComplete_WithInvalidChallengeId_ReturnsError()
130+
{
131+
// Attempting to complete login with invalid challenge should fail
132+
// The endpoint validates the challenge ID and returns an error
133+
var request = new
134+
{
135+
ChallengeId = "non-existent-challenge-id",
136+
OptionsJson = "{}",
137+
AssertionResponse = new
138+
{
139+
Id = "ZmFrZS1jcmVkZW50aWFsLWlk", // base64url encoded
140+
RawId = "ZmFrZS1jcmVkZW50aWFsLWlk",
141+
Type = "public-key",
142+
Response = new
143+
{
144+
AuthenticatorData = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
145+
ClientDataJson = "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiYWFhYSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyJ9",
146+
Signature = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
147+
UserHandle = (string?)null,
148+
},
149+
},
150+
};
151+
152+
var response = await _client.PostAsJsonAsync("/auth/login/complete", request);
153+
154+
// Should return an error (either BadRequest for validation or Problem for processing)
155+
Assert.True(
156+
response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError,
157+
$"Expected error status code but got {response.StatusCode}"
158+
);
159+
}
160+
161+
[Fact]
162+
public async Task RegisterComplete_WithInvalidChallengeId_ReturnsError()
163+
{
164+
// Attempting to complete registration with invalid challenge should fail
165+
var request = new
166+
{
167+
ChallengeId = "non-existent-challenge-id",
168+
OptionsJson = "{}",
169+
AttestationResponse = new
170+
{
171+
Id = "ZmFrZS1jcmVkZW50aWFsLWlk", // base64url encoded
172+
RawId = "ZmFrZS1jcmVkZW50aWFsLWlk",
173+
Type = "public-key",
174+
Response = new
175+
{
176+
AttestationObject = "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjE",
177+
ClientDataJson = "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiYWFhYSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyJ9",
178+
},
179+
},
180+
};
181+
182+
var response = await _client.PostAsJsonAsync("/auth/register/complete", request);
183+
184+
// Should return an error (either BadRequest for validation or Problem for processing)
185+
Assert.True(
186+
response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError,
187+
$"Expected error status code but got {response.StatusCode}"
188+
);
189+
}
190+
191+
[Fact]
192+
public async Task Session_WithoutToken_ReturnsUnauthorized()
193+
{
194+
var response = await _client.GetAsync("/auth/session");
195+
196+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
197+
}
198+
199+
[Fact]
200+
public async Task Session_WithInvalidToken_ReturnsUnauthorized()
201+
{
202+
_client.DefaultRequestHeaders.Authorization =
203+
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "invalid-token");
204+
205+
var response = await _client.GetAsync("/auth/session");
206+
207+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
208+
}
209+
210+
[Fact]
211+
public async Task Logout_WithoutToken_ReturnsUnauthorized()
212+
{
213+
var response = await _client.PostAsync("/auth/logout", null);
214+
215+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
216+
}
217+
}
218+
219+
/// <summary>
220+
/// Tests for Base64Url encoding used in WebAuthn credential IDs.
221+
/// </summary>
222+
public sealed class Base64UrlTests
223+
{
224+
[Fact]
225+
public void Encode_ProducesUrlSafeOutput()
226+
{
227+
// Standard base64 uses + and /, base64url uses - and _
228+
var input = new byte[] { 0xfb, 0xff, 0xfe }; // Would produce +//+ in standard base64
229+
230+
var result = Base64Url.Encode(input);
231+
232+
Assert.DoesNotContain("+", result);
233+
Assert.DoesNotContain("/", result);
234+
Assert.DoesNotContain("=", result);
235+
Assert.Contains("-", result); // Should use - instead of +
236+
Assert.Contains("_", result); // Should use _ instead of /
237+
}
238+
239+
[Fact]
240+
public void Encode_Decode_RoundTrip()
241+
{
242+
var original = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
243+
244+
var encoded = Base64Url.Encode(original);
245+
var decoded = Base64Url.Decode(encoded);
246+
247+
Assert.Equal(original, decoded);
248+
}
249+
250+
[Fact]
251+
public void Decode_HandlesNoPadding()
252+
{
253+
// base64url typically omits padding
254+
var encoded = "AQIDBA"; // No = padding
255+
256+
var decoded = Base64Url.Decode(encoded);
257+
258+
Assert.Equal(new byte[] { 1, 2, 3, 4 }, decoded);
259+
}
260+
261+
[Fact]
262+
public void Decode_HandlesUrlSafeCharacters()
263+
{
264+
// Test decoding with - and _ (url-safe chars)
265+
var encoded = "-_8"; // base64url for 0xfb, 0xff
266+
267+
var decoded = Base64Url.Decode(encoded);
268+
269+
Assert.Equal(new byte[] { 0xfb, 0xff }, decoded);
270+
}
271+
272+
[Fact]
273+
public void Encode_MatchesWebAuthnCredentialIdFormat()
274+
{
275+
// WebAuthn credential IDs use base64url encoding
276+
// This test verifies our encoding matches the expected format
277+
var credentialId = new byte[]
278+
{
279+
0x01,
280+
0x02,
281+
0x03,
282+
0x04,
283+
0x05,
284+
0x06,
285+
0x07,
286+
0x08,
287+
0x09,
288+
0x0a,
289+
0x0b,
290+
0x0c,
291+
0x0d,
292+
0x0e,
293+
0x0f,
294+
0x10,
295+
};
296+
297+
var encoded = Base64Url.Encode(credentialId);
298+
299+
// Should be AQIDBAUGBwgJCgsMDQ4PEA (no padding)
300+
Assert.Equal("AQIDBAUGBwgJCgsMDQ4PEA", encoded);
301+
302+
// Verify round-trip
303+
var decoded = Base64Url.Decode(encoded);
304+
Assert.Equal(credentialId, decoded);
305+
}
306+
}

0 commit comments

Comments
 (0)