-
Notifications
You must be signed in to change notification settings - Fork 332
Expand file tree
/
Copy pathJwtTokenAuthenticationUnitTests.cs
More file actions
493 lines (445 loc) · 23.6 KB
/
JwtTokenAuthenticationUnitTests.cs
File metadata and controls
493 lines (445 loc) · 23.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.AuthenticationHelpers;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Core.Configurations;
using Azure.DataApiBuilder.Service.Tests.Authentication.Helpers;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Azure.DataApiBuilder.Service.Tests.Authentication
{
/// <summary>
/// Tests that JwtMiddleware properly return 401 when a JWT token
/// is not valid nor trusted based on config values and token content.
/// - Usage of RSASecurityKey to simulate JWT signed with alg: PS256,
/// as RSA spec requires the alg in new token signing apps.
/// https://datatracker.ietf.org/doc/html/rfc8017#section-8
/// - Usage of X509Certificate2 to simulate signed tokens that include {kid} claim in JWT header
/// This claim is optional, though used in providers like Azure AD.
/// - Exception language from aspnetcore:
/// https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/JwtBearer/src/JwtBearerHandler.cs#L309-L339
/// </summary>
[TestClass]
public class JwtTokenAuthenticationUnitTests
{
private const string AUDIENCE = "d727a7e8-1af4-4ce0-8c56-f3107f10bbfd";
private const string BAD_AUDIENCE = "1337-314159";
private const string ISSUER = "https://login.microsoftonline.com/291bf275-ea78-4cde-84ea-21309a43a567/v2.0";
private const string LOCAL_ISSUER = "https://goodissuer.com";
private const string BAD_ISSUER = "https://badactor.com";
private const string CHALLENGE_HEADER = "WWW-Authenticate";
#region Positive Tests
/// <summary>
/// JWT is valid as it contains no errors caught by negative tests
/// library(Microsoft.AspNetCore.Authentication.JwtBearer) validation methods
/// </summary>
[DataTestMethod]
[DataRow(null, DisplayName = "Authenticated role - X-MS-API-ROLE is not sent")]
[DataRow("author", DisplayName = "Authenticated role - existing X-MS-API-ROLE is honored")]
[TestMethod]
public async Task TestValidToken(string clientRoleHeader)
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = CreateJwt(
audience: AUDIENCE,
issuer: LOCAL_ISSUER,
notBefore: DateTime.UtcNow.AddDays(-1),
expirationTime: DateTime.UtcNow.AddDays(1),
signingKey: key
);
HttpContext postMiddlewareContext =
await SendRequestAndGetHttpContextState(
key,
token,
clientRoleHeader);
Assert.IsTrue(postMiddlewareContext.User.Identity.IsAuthenticated);
Assert.AreEqual(
expected: (int)HttpStatusCode.OK,
actual: postMiddlewareContext.Response.StatusCode);
Assert.AreEqual(
expected: clientRoleHeader is not null ? clientRoleHeader : AuthorizationType.Authenticated.ToString(),
actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER],
ignoreCase: true);
}
/// <summary>
/// Test to validate that the user request is treated with anonymous role when
/// the jwt token is missing.
/// </summary>
/// <returns></returns>
[DataTestMethod]
[DataRow(null, DisplayName = "Anonymous role - X-MS-API-ROLE is not sent")]
[DataRow("author", DisplayName = "Anonymous role - existing X-MS-API-ROLE is not honored")]
[TestMethod]
public async Task TestMissingJwtToken(string clientRoleHeader)
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = null;
HttpContext postMiddlewareContext
= await SendRequestAndGetHttpContextState(key, token, clientRoleHeader);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
Assert.AreEqual(
expected: (int)HttpStatusCode.OK,
actual: postMiddlewareContext.Response.StatusCode);
Assert.AreEqual(
expected: AuthorizationType.Anonymous.ToString(),
actual: postMiddlewareContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER],
ignoreCase: true);
}
/// <summary>
/// JWT is expired and should not be accepted.
/// </summary>
[TestMethod]
public async Task TestInvalidToken_LifetimeExpired()
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = CreateJwt(
audience: AUDIENCE,
issuer: LOCAL_ISSUER,
notBefore: DateTime.UtcNow.AddDays(-2),
expirationTime: DateTime.UtcNow.AddDays(-1),
signingKey: key
);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, token);
Assert.AreEqual(
expected: (int)HttpStatusCode.Unauthorized,
actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The token expired at"));
}
/// <summary>
/// JWT notBefore date is in the future.
/// JWT is not YET valid and causes validation failure.
/// </summary>
[TestMethod]
public async Task TestInvalidToken_NotYetValid()
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = CreateJwt(
audience: AUDIENCE,
issuer: LOCAL_ISSUER,
notBefore: DateTime.UtcNow.AddDays(1),
expirationTime: DateTime.UtcNow.AddDays(2),
signingKey: key
);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, token);
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The token is not valid before"));
}
/// <summary>
/// JWT contains audience not configured in TestServer Authentication options.
/// Mismatch to configuration causes validation failure.
/// </summary>
[TestMethod]
public async Task TestInvalidToken_BadAudience()
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = CreateJwt(audience: BAD_AUDIENCE, issuer: LOCAL_ISSUER, signingKey: key);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, token);
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
// Microsoft.IdentityModel.Tokens version 8.8+ scrubs the Audience from the error message
// This behavior can be disabled with AppContext.SetSwitch("Switch.Microsoft.IdentityModel.DoNotScrubExceptions", true);
// See https://aka.ms/identitymodel/app-context-switches
string expectedAudienceInErrorMessage = AppContext.TryGetSwitch("Switch.Microsoft.IdentityModel.DoNotScrubExceptions", out bool isExceptionScrubbingDisabled) && isExceptionScrubbingDisabled
? BAD_AUDIENCE
: "(null)";
Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The audience '{expectedAudienceInErrorMessage}' is invalid"));
}
/// <summary>
/// JWT contains issuer not configured in TestServer Authentication options.
/// Mismatch to configuration causes validation failure.
/// </summary>
[TestMethod]
public async Task TestInvalidToken_BadIssuer()
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = CreateJwt(audience: AUDIENCE, issuer: BAD_ISSUER, signingKey: key);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, token);
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The issuer '{BAD_ISSUER}' is invalid"));
}
/// <summary>
/// JWT signed with unrecognized/unconfigured cert.
/// Resulting in unrecognized (kid) claim value
/// </summary>
[TestMethod]
public async Task TestInvalidToken_InvalidSigningKey()
{
X509Certificate2 selfSignedCert = AuthTestCertHelper.CreateSelfSignedCert(hostName: LOCAL_ISSUER);
X509Certificate2 altCert = AuthTestCertHelper.CreateSelfSignedCert(hostName: BAD_ISSUER);
SecurityKey key = new X509SecurityKey(selfSignedCert);
SecurityKey badKey = new X509SecurityKey(altCert);
string token = CreateJwt(audience: AUDIENCE, issuer: LOCAL_ISSUER, signingKey: badKey);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, token);
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
Assert.IsTrue(headerValue[0].Contains("invalid_token") && headerValue[0].Contains($"The signature key was not found"));
}
/// <summary>
/// JWT signed with key not registered in the server should result in failed authentication
/// characterized with an HTTP 401 Unauthorized response due to an "invalid signature" error.
/// If this test fails, check the console output for the error:
/// "Bearer was not authenticated. Failure message: IDX10503: Signature validation failed."
/// </summary>
[TestMethod("JWT signed with unrecognized/unconfigured key, results in signature key not found")]
public async Task TestInvalidToken_InvalidSignature()
{
// Arrange
RsaSecurityKey tokenIssuerSigningKey = CreateRsaSigningKeyForTest();
// Create a JWT token with a signing key differnt than the key
// used by the server to validate the IssuerSigningKey
// -> Exercises the ValidateIssuerSigningKey validator
// https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/wiki/ValidatingTokens
RsaSecurityKey badKey = CreateRsaSigningKeyForTest();
string badToken = CreateJwt(audience: AUDIENCE, issuer: LOCAL_ISSUER, signingKey: badKey);
// Act
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(
key: tokenIssuerSigningKey,
token: badToken);
// Assert
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
bool isResponseContentValid = headerValue[0].Contains("invalid_token") && headerValue[0].Contains("The signature key was not found");
Assert.IsTrue(condition: isResponseContentValid, message: "Expected JWT signature validation failure.");
}
/// <summary>
/// JWT with intentionally scrambled signature.
/// JWT signed with cert adding KID (keyID) claim to token.
/// Even with valid key, invalid signature still fails validation.
/// </summary>
[TestMethod]
public async Task TestInvalidToken_InvalidSignatureUsingCert()
{
X509Certificate2 selfSignedCert = AuthTestCertHelper.CreateSelfSignedCert(hostName: LOCAL_ISSUER);
SecurityKey key = new X509SecurityKey(selfSignedCert);
string token = CreateJwt(audience: AUDIENCE, issuer: LOCAL_ISSUER, signingKey: key);
string tokenForgedSignature = ModifySignature(token, removeSig: false);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, tokenForgedSignature);
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
Assert.IsTrue(headerValue[0].Contains("invalid_token"));
}
/// <summary>
/// JWT token striped of signature should fail (401) even if all other validation passes.
/// Challenge header (WWW-Authenticate) only states invalid_token here.
/// </summary>
[TestMethod("JWT with no signature should result in 401")]
public async Task TestInvalidToken_NoSignature()
{
RsaSecurityKey key = new(RSA.Create(2048));
string token = CreateJwt(audience: AUDIENCE, issuer: LOCAL_ISSUER, signingKey: key);
string tokenNoSignature = ModifySignature(token, removeSig: true);
HttpContext postMiddlewareContext = await SendRequestAndGetHttpContextState(key, tokenNoSignature);
Assert.AreEqual(expected: (int)HttpStatusCode.Unauthorized, actual: postMiddlewareContext.Response.StatusCode);
Assert.IsFalse(postMiddlewareContext.User.Identity.IsAuthenticated);
StringValues headerValue = GetChallengeHeader(postMiddlewareContext);
Assert.IsTrue(headerValue[0].Contains("invalid_token"));
}
#endregion
#region Helper Methods
/// <summary>
/// Configures test server with bare minimum middleware
/// and configures Authentication options with passed in SecurityKey
/// </summary>
/// <param name="key"></param>
/// <returns>IHost</returns>
private static async Task<IHost> CreateWebHostCustomIssuer(SecurityKey key)
{
// Setup RuntimeConfigProvider object for the pipeline.
MockFileSystem fileSystem = new();
FileSystemRuntimeConfigLoader fileSystemRuntimeConfigLoader = new(new MockFileSystem());
AuthenticationOptions authOptions = new()
{
Provider = "AzureAD"
};
RuntimeConfig runtimeConfig = RuntimeConfigAuthHelper.CreateTestConfigWithAuthNProvider(authOptions);
fileSystemRuntimeConfigLoader.RuntimeConfig = runtimeConfig;
RuntimeConfigProvider runtimeConfigProvider = new(fileSystemRuntimeConfigLoader);
return await new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddAuthentication(defaultScheme: JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Audience = AUDIENCE;
options.TokenValidationParameters = new()
{
// Valiate the JWT Audience (aud) claim
ValidAudience = AUDIENCE,
ValidateAudience = true,
// Validate the JWT Issuer (iss) claim
ValidIssuer = LOCAL_ISSUER,
ValidateIssuer = true,
// The signing key must match
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
// Lifetime
ValidateLifetime = true
};
});
services.AddAuthorization();
services.AddSingleton<RuntimeConfigProvider>(sp => runtimeConfigProvider);
})
.ConfigureLogging(o =>
{
o.AddFilter(levelFilter => levelFilter <= LogLevel.Information);
o.AddDebug();
o.AddConsole();
})
.Configure(app =>
{
app.UseAuthentication();
app.UseClientRoleHeaderAuthenticationMiddleware();
// app.Run acts as terminating middleware to return 200 if we reach it. Without this,
// the Middleware pipeline will return 404 by default.
app.Run(async (context) =>
{
context.Response.StatusCode = (int)HttpStatusCode.OK;
await context.Response.WriteAsync("Successfully validated token!");
await context.Response.StartAsync();
});
});
})
.StartAsync();
}
/// <summary>
/// Creates the TestServer with the minimum middleware setup necessary to
/// test JwtAuthenticationMiddlware
/// Sends a request with the passed in token to the TestServer created.
/// </summary>
/// <param name="key">The signing key used for TestServer's IssuerSigningKey field.</param>
/// <param name="token">The JWT value to test against the TestServer</param>
/// <returns></returns>
private static async Task<HttpContext> SendRequestAndGetHttpContextState(
SecurityKey key,
string token,
string clientRoleHeader = null)
{
using IHost host = await CreateWebHostCustomIssuer(key);
TestServer server = host.GetTestServer();
return await server.SendAsync(context =>
{
if (token is not null)
{
StringValues headerValue = new(new string[] { $"Bearer {token}" });
KeyValuePair<string, StringValues> authHeader = new("Authorization", headerValue);
context.Request.Headers.Add(authHeader);
}
if (clientRoleHeader is not null)
{
KeyValuePair<string, StringValues> easyAuthHeader =
new(AuthorizationResolver.CLIENT_ROLE_HEADER, clientRoleHeader);
context.Request.Headers.Add(easyAuthHeader);
}
context.Request.Scheme = "https";
});
}
/// <summary>
/// Creates a JWT token with self signed cert or RSAKey.
/// Resources:
/// https://devblogs.microsoft.com/dotnet/jwt-validation-and-authorization-in-asp-net-core/
/// https://stackoverflow.com/questions/59255124/postman-returns-401-despite-the-valid-token-distributed-for-a-secure-endpoint
/// https://jasonwatmore.com/post/2020/07/21/aspnet-core-3-create-and-validate-jwt-tokens-use-custom-jwt-middleware
/// </summary>
/// <param name="signingCert"></param>
/// <returns></returns>
private static string CreateJwt(
string audience = AUDIENCE,
string issuer = ISSUER,
DateTime? notBefore = null,
DateTime? expirationTime = null,
SecurityKey signingKey = null)
{
JsonWebTokenHandler jsonWebTokenHandler = new();
SecurityTokenDescriptor tokenDescriptor = new()
{
Audience = audience,
Issuer = issuer,
Subject = new ClaimsIdentity(new[] { new Claim("id", "1337-314159"), new Claim("userId", "777"), new Claim(ClaimTypes.Name, "ladybird") }),
NotBefore = notBefore,
Expires = expirationTime,
SigningCredentials = new(key: signingKey, algorithm: SecurityAlgorithms.RsaSha256)
};
return jsonWebTokenHandler.CreateToken(tokenDescriptor);
}
/// <summary>
/// The JWS representation of a JWT is formatted as: Header.Payload.Signature
/// RFC: https://www.rfc-editor.org/rfc/rfc7515.html#appendix-A.3.1
///
/// Scramble or arbitrarily set the Signature value after the second period (.)
/// This method assumes a properly formatted, but not necessarily valid, JWT.
///
/// remove(false) -> JWT of form Header.Payload.ModifiedSignature
/// remove(true) -> JWT of form Header.Payload
/// </summary>
/// <param name="token"></param>
/// <returns>Modified JWT</returns>
private static string ModifySignature(string token, bool removeSig)
{
int headerEnd = token.IndexOf('.');
int signatureBegin = token.IndexOf('.', headerEnd + 1);
if (removeSig)
{
return token.Remove(signatureBegin);
}
else
{
return token.Insert(signatureBegin + 1, "abcdefg");
}
}
/// <summary>
/// Returns the value of the challenge header
/// index[0] value:
/// "Bearer error=\"invalid_token\", error_description=\"The audience '1337-314159' is invalid\""
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
private static StringValues GetChallengeHeader(HttpContext context)
{
Assert.IsTrue(context.Response.Headers.ContainsKey(CHALLENGE_HEADER));
return context.Response.Headers[CHALLENGE_HEADER];
}
private static RsaSecurityKey CreateRsaSigningKeyForTest()
{
RsaSecurityKey key = new(RSA.Create(2048))
{
KeyId = Guid.NewGuid().ToString()
};
return key;
}
#endregion
}
}