-
Notifications
You must be signed in to change notification settings - Fork 567
SMART on FHIR Token Introspection Endpoint #5257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
SMART on FHIR Token Introspection Endpoint #5257
Conversation
src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs
Fixed
Show fixed
Hide fixed
| public void GivenInvalidSignatureToken_WhenIntrospect_ThenReturnsInactive() | ||
| { | ||
| // Arrange - Create token with different signing key | ||
| var differentRsa = RSA.Create(2048); |
Check warning
Code scanning / CodeQL
Missing Dispose call on local IDisposable Warning
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 1 day ago
To fix the problem, we should ensure that the RSA instance created at line 144 is properly disposed after use. The best approach is to use a using statement (or in modern C# a using declaration) to wrap the lifetime of differentRsa so it will be disposed of automatically after we're finished with it. This requires wrapping the block of code where differentRsa is used (lines 144–162) inside a using block or using declaration. This change will be localized to the test method GivenInvalidSignatureToken_WhenIntrospect_ThenReturnsInactive() and does not affect the functionality of the test.
-
Copy modified line R144 -
Copy modified lines R146-R150 -
Copy modified lines R152-R160 -
Copy modified lines R162-R163 -
Copy modified lines R165-R173
| @@ -141,26 +141,36 @@ | ||
| public void GivenInvalidSignatureToken_WhenIntrospect_ThenReturnsInactive() | ||
| { | ||
| // Arrange - Create token with different signing key | ||
| var differentRsa = RSA.Create(2048); | ||
| var differentKey = new RsaSecurityKey(differentRsa); | ||
| var differentCredentials = new SigningCredentials(differentKey, SecurityAlgorithms.RsaSha256); | ||
|
|
||
| var tokenHandler = new JwtSecurityTokenHandler(); | ||
| var tokenDescriptor = new SecurityTokenDescriptor | ||
| using (var differentRsa = RSA.Create(2048)) | ||
| { | ||
| Subject = new ClaimsIdentity(new[] | ||
| var differentKey = new RsaSecurityKey(differentRsa); | ||
| var differentCredentials = new SigningCredentials(differentKey, SecurityAlgorithms.RsaSha256); | ||
|
|
||
| var tokenHandler = new JwtSecurityTokenHandler(); | ||
| var tokenDescriptor = new SecurityTokenDescriptor | ||
| { | ||
| new Claim("sub", "test-user"), | ||
| }), | ||
| Expires = DateTime.UtcNow.AddHours(1), | ||
| Issuer = _issuer, | ||
| Audience = _audience, | ||
| SigningCredentials = differentCredentials, // Wrong key | ||
| }; | ||
| Subject = new ClaimsIdentity(new[] | ||
| { | ||
| new Claim("sub", "test-user"), | ||
| }), | ||
| Expires = DateTime.UtcNow.AddHours(1), | ||
| Issuer = _issuer, | ||
| Audience = _audience, | ||
| SigningCredentials = differentCredentials, // Wrong key | ||
| }; | ||
|
|
||
| var token = tokenHandler.CreateToken(tokenDescriptor); | ||
| var tokenString = tokenHandler.WriteToken(token); | ||
| var token = tokenHandler.CreateToken(tokenDescriptor); | ||
| var tokenString = tokenHandler.WriteToken(token); | ||
|
|
||
| // Act | ||
| var result = _controller.Introspect(tokenString); | ||
|
|
||
| // Assert | ||
| var okResult = Assert.IsType<OkObjectResult>(result); | ||
| var response = Assert.IsType<Dictionary<string, object>>(okResult.Value); | ||
| Assert.True(response.TryGetValue("active", out var active)); | ||
| Assert.False((bool)active); | ||
| } | ||
| // Act | ||
| var result = _controller.Introspect(tokenString); | ||
|
|
src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs
Fixed
Show fixed
Hide fixed
src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs
Fixed
Show fixed
Hide fixed
src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs
Fixed
Show fixed
Hide fixed
src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs
Fixed
Show fixed
Hide fixed
src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs
Fixed
Show fixed
Hide fixed
src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs
Fixed
Show fixed
Hide fixed
src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs
Fixed
Show fixed
Hide fixed
|
|
||
| _securityConfiguration = securityConfiguration.Value; | ||
| _logger = logger; | ||
| _tokenHandler = new JwtSecurityTokenHandler(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to look at an implementation here using DI and not typing to new
src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
Show resolved
Hide resolved
src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
Show resolved
Hide resolved
src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
Show resolved
Hide resolved
src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
Fixed
Show fixed
Hide fixed
…d improve logging
…ment and enhance assertions
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; |
Check warning
Code scanning / CodeQL
Missing Dispose call on local IDisposable Warning test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
The best and most reliable way to fix the missing Dispose call on the local HttpRequestMessage is to wrap its construction and usage in a using block. This ensures that, regardless of exceptions or early returns, the object's resources are correctly disposed of. In the context of async code and C# 8.0 and above, using is supported with local variables even when the body of the using includes await expressions.
Within the method GivenMissingTokenParameter_WhenIntrospecting_ThenReturnsBadRequest, modify lines 177-184 to enclose the declaration of request and the following lines which require it within a using statement. This will involve placing a using declaration for the HttpRequestMessage and adjusting the code so the disposable is cleaned up after the async call, but before assertions that access only the response values.
No new imports are needed, and no method signatures need to be changed.
-
Copy modified line R178 -
Copy modified lines R181-R183 -
Copy modified line R185 -
Copy modified lines R187-R193
| @@ -175,14 +175,22 @@ | ||
|
|
||
| // Act - Send request without token parameter | ||
| var content = new FormUrlEncodedContent(new Dictionary<string, string>()); | ||
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| using (var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; | ||
| request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); | ||
| }) | ||
| { | ||
| request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); | ||
|
|
||
| var introspectionResponse = await _httpClient.SendAsync(request); | ||
| var introspectionResponse = await _httpClient.SendAsync(request); | ||
|
|
||
| // Assert | ||
| Assert.Equal(HttpStatusCode.BadRequest, introspectionResponse.StatusCode); | ||
|
|
||
| var responseJson = await introspectionResponse.Content.ReadAsStringAsync(); | ||
| Assert.Contains("token parameter is required", responseJson, StringComparison.OrdinalIgnoreCase); | ||
| } | ||
|
|
||
| // Assert | ||
| Assert.Equal(HttpStatusCode.BadRequest, introspectionResponse.StatusCode); | ||
|
|
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; |
Check warning
Code scanning / CodeQL
Missing Dispose call on local IDisposable Warning test
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; |
Check warning
Code scanning / CodeQL
Missing Dispose call on local IDisposable Warning test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
To fix the issue, we need to ensure that the HttpRequestMessage created on line 226, as well as its associated content (the StringContent object created on line 225), are properly disposed after use. The best and least intrusive way to do this is to wrap the creation and usage of both objects in a using statement. Since the request and content are both disposable and their scope is limited to this method, we can use nested or a single combined using declaration (C# 8.0 and above). This can be achieved by replacing the variable declarations and usage block with a using block that ensures disposal after the request has been sent and the assertion is performed. This approach maintains existing functionality and only affects the resource management pattern within the relevant test method.
-
Copy modified lines R225-R226 -
Copy modified lines R229-R231 -
Copy modified line R233 -
Copy modified lines R235-R237
| @@ -222,17 +222,19 @@ | ||
| var accessToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal); | ||
|
|
||
| // Act - Send with wrong content type | ||
| var content = new StringContent("{\"token\": \"test\"}", System.Text.Encoding.UTF8, "application/json"); | ||
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| using (var content = new StringContent("{\"token\": \"test\"}", System.Text.Encoding.UTF8, "application/json")) | ||
| using (var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; | ||
| request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); | ||
| }) | ||
| { | ||
| request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); | ||
|
|
||
| var introspectionResponse = await _httpClient.SendAsync(request); | ||
| var introspectionResponse = await _httpClient.SendAsync(request); | ||
|
|
||
| // Assert - RFC 7662 requires application/x-www-form-urlencoded | ||
| Assert.Equal(HttpStatusCode.UnsupportedMediaType, introspectionResponse.StatusCode); | ||
| // Assert - RFC 7662 requires application/x-www-form-urlencoded | ||
| Assert.Equal(HttpStatusCode.UnsupportedMediaType, introspectionResponse.StatusCode); | ||
| } | ||
| } | ||
|
|
||
| [Fact] |
| var content = new FormUrlEncodedContent(new Dictionary<string, string> | ||
| { | ||
| { "grant_type", "client_credentials" }, | ||
| { "client_id", testApplication.ClientId }, | ||
| { "client_secret", testApplication.ClientSecret }, | ||
| { "scope", "fhir-api" }, | ||
| }); |
Check warning
Code scanning / CodeQL
Missing Dispose call on local IDisposable Warning test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
To fix this problem, we need to ensure that the FormUrlEncodedContent instance created in GetAccessTokenAsync is disposed of properly—i.e., its Dispose method is invoked when it is no longer needed. The most concise and idiomatic way to do this is to wrap its usage in a using statement.
Specifically, in GetAccessTokenAsync, wrap the section of code that uses content from its creation at line 269 to the point where it is passed to _httpClient.PostAsync (and ensure that we do not use content beyond that point). Since PostAsync does not require content to be alive after invocation, we can make the using block encompass the call to PostAsync. In async code, this means using the C# 8.0 using declaration or otherwise nesting a regular using block. Since all usages are within the method and are awaited before further usage, this is straightforward.
No new imports or other changes are required beyond converting the code to use a using block for FormUrlEncodedContent inside GetAccessTokenAsync.
-
Copy modified line R269 -
Copy modified lines R275-R278 -
Copy modified lines R280-R281 -
Copy modified lines R283-R284
| @@ -266,21 +266,22 @@ | ||
| /// </summary> | ||
| private async Task<string> GetAccessTokenAsync(TestApplication testApplication) | ||
| { | ||
| var content = new FormUrlEncodedContent(new Dictionary<string, string> | ||
| using (var content = new FormUrlEncodedContent(new Dictionary<string, string> | ||
| { | ||
| { "grant_type", "client_credentials" }, | ||
| { "client_id", testApplication.ClientId }, | ||
| { "client_secret", testApplication.ClientSecret }, | ||
| { "scope", "fhir-api" }, | ||
| }); | ||
| })) | ||
| { | ||
| var response = await _httpClient.PostAsync(_tokenUri, content); | ||
| response.EnsureSuccessStatusCode(); | ||
|
|
||
| var response = await _httpClient.PostAsync(_tokenUri, content); | ||
| response.EnsureSuccessStatusCode(); | ||
| var responseJson = await response.Content.ReadAsStringAsync(); | ||
| var tokenResponse = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(responseJson); | ||
|
|
||
| var responseJson = await response.Content.ReadAsStringAsync(); | ||
| var tokenResponse = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(responseJson); | ||
|
|
||
| return tokenResponse["access_token"].GetString(); | ||
| return tokenResponse["access_token"].GetString(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> |
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; |
Check warning
Code scanning / CodeQL
Missing Dispose call on local IDisposable Warning test
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
The best way to fix this issue is to wrap the creation and use of the HttpRequestMessage object in a using statement. This ensures that it will be disposed of as soon as it is no longer needed, even if exceptions occur. In this context, since the code creates the request, sends it via HttpClient.SendAsync, and returns the corresponding HttpResponseMessage, we must ensure that disposal happens after the send. However, since the method returns the HttpResponseMessage, not the request, using an async using block will suffice.
To implement the fix, in IntrospectTokenAsync, lines 295–302, replace the direct creation and sending of the request with a using block. This change does not require new imports, as using and IDisposable are built-in to .NET.
-
Copy modified line R296 -
Copy modified lines R299-R303
| @@ -293,13 +293,14 @@ | ||
| { "token", tokenToIntrospect }, | ||
| }); | ||
|
|
||
| var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| await using (var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri) | ||
| { | ||
| Content = content, | ||
| }; | ||
| request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", authToken); | ||
|
|
||
| return await _httpClient.SendAsync(request); | ||
| }) | ||
| { | ||
| request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", authToken); | ||
| return await _httpClient.SendAsync(request); | ||
| } | ||
| } | ||
| } | ||
| } |
| Assert.True(response.ContainsKey("sub")); | ||
| Assert.True(response.ContainsKey("iss")); | ||
| Assert.True(response.ContainsKey("aud")); | ||
| Assert.True(response.ContainsKey("exp")); |
Check notice
Code scanning / CodeQL
Inefficient use of ContainsKey Note test
indexer
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
The best way to fix this issue is to replace the check for response.ContainsKey("exp") and subsequent access to response["exp"] with a single call to response.TryGetValue("exp", out JsonElement expElement). This only performs one dictionary lookup and is more efficient. The assertion should then use expElement.GetInt64(). The change should be made in the method GivenValidToken_WhenIntrospecting_ThenReturnsActiveWithStandardClaims in lines 63 and 67 of test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/TokenIntrospectionTests.cs. You may need to add a local variable for expElement. No new imports or external methods are required.
-
Copy modified line R67
| @@ -64,7 +64,7 @@ | ||
| Assert.True(response.ContainsKey("client_id")); | ||
|
|
||
| // Verify timestamps are Unix timestamps (positive numbers) | ||
| Assert.True(response["exp"].GetInt64() > 0); | ||
| Assert.True(response.TryGetValue("exp", out var expElement) && expElement.GetInt64() > 0); | ||
| if (response.ContainsKey("iat")) | ||
| { | ||
| Assert.True(response["iat"].GetInt64() > 0); |
|
|
||
| // Verify timestamps are Unix timestamps (positive numbers) | ||
| Assert.True(response["exp"].GetInt64() > 0); | ||
| if (response.ContainsKey("iat")) |
Check notice
Code scanning / CodeQL
Inefficient use of ContainsKey Note test
indexer
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
To fix this performance issue, replace the existing check:
if (response.ContainsKey("iat"))
{
Assert.True(response["iat"].GetInt64() > 0);
}with a TryGetValue-based idiom, which combines both the existence check and the access into a single operation. Specifically, declare a variable for the out value, use response.TryGetValue("iat", out var iatValue), and in the body use iatValue.GetInt64(). This change should be made directly in the test method, at the exact code location, keeping naming conventions and context identical.
No additional imports or helper methods are necessary, as TryGetValue is a method on Dictionary<TKey, TValue>.
-
Copy modified line R68 -
Copy modified line R70
| @@ -65,9 +65,9 @@ | ||
|
|
||
| // Verify timestamps are Unix timestamps (positive numbers) | ||
| Assert.True(response["exp"].GetInt64() > 0); | ||
| if (response.ContainsKey("iat")) | ||
| if (response.TryGetValue("iat", out var iatValue)) | ||
| { | ||
| Assert.True(response["iat"].GetInt64() > 0); | ||
| Assert.True(iatValue.GetInt64() > 0); | ||
| } | ||
| } | ||
|
|
| Assert.True(response["active"].GetBoolean()); | ||
|
|
||
| // Scope should be a space-separated string, not an array | ||
| if (response.ContainsKey("scope")) |
Check notice
Code scanning / CodeQL
Inefficient use of ContainsKey Note test
indexer
Inefficient use of 'ContainsKey' and
indexer
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
To fix the problem, replace the use of response.ContainsKey("scope") followed by repeated dictionary lookups with a single call to response.TryGetValue("scope", out var scopeElement). Specifically, in the affected test method, replace the if block:
if (response.ContainsKey("scope"))
{
Assert.Equal(JsonValueKind.String, response["scope"].ValueKind);
var scope = response["scope"].GetString();
Assert.NotEmpty(scope);
}with:
if (response.TryGetValue("scope", out var scopeElement))
{
Assert.Equal(JsonValueKind.String, scopeElement.ValueKind);
var scope = scopeElement.GetString();
Assert.NotEmpty(scope);
}This change ensures that the key existence check and retrieval are combined efficiently, without changing functionality. No new methods or imports are necessary, as all required functionality is part of the standard library.
Make the edit only in the relevant section of the file test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/TokenIntrospectionTests.cs, and ensure any usages of response["scope"] in this scope are changed to use the local variable for correctness and efficiency.
-
Copy modified line R90 -
Copy modified lines R92-R93
| @@ -87,10 +87,10 @@ | ||
| Assert.True(response["active"].GetBoolean()); | ||
|
|
||
| // Scope should be a space-separated string, not an array | ||
| if (response.ContainsKey("scope")) | ||
| if (response.TryGetValue("scope", out var scopeElement)) | ||
| { | ||
| Assert.Equal(JsonValueKind.String, response["scope"].ValueKind); | ||
| var scope = response["scope"].GetString(); | ||
| Assert.Equal(JsonValueKind.String, scopeElement.ValueKind); | ||
| var scope = scopeElement.GetString(); | ||
| Assert.NotEmpty(scope); | ||
| } | ||
| } |
| Assert.True(response["active"].GetBoolean()); | ||
|
|
||
| // SMART clients should have fhirUser claim | ||
| if (response.ContainsKey("fhirUser")) |
Check notice
Code scanning / CodeQL
Inefficient use of ContainsKey Note test
indexer
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
To fix the problem, on lines 114-116 in TokenIntrospectionTests.cs, replace the use of ContainsKey followed by accessing via the indexer with a single call to TryGetValue. This involves declaring a local JsonElement variable to store the value, and then using it if the key existed. The scope of the fix only encompasses these lines, and no additional imports or helper methods are needed.
-
Copy modified line R114 -
Copy modified line R116
| @@ -111,9 +111,9 @@ | ||
| Assert.True(response["active"].GetBoolean()); | ||
|
|
||
| // SMART clients should have fhirUser claim | ||
| if (response.ContainsKey("fhirUser")) | ||
| if (response.TryGetValue("fhirUser", out var fhirUserElement)) | ||
| { | ||
| var fhirUser = response["fhirUser"].GetString(); | ||
| var fhirUser = fhirUserElement.GetString(); | ||
| Assert.NotEmpty(fhirUser); | ||
|
|
||
| // fhirUser should be a full URL to a Practitioner, Patient, Person, or RelatedPerson |
| var response = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(responseJson); | ||
|
|
||
| // Verify inactive status | ||
| Assert.True(response.ContainsKey("active")); |
Check notice
Code scanning / CodeQL
Inefficient use of ContainsKey Note test
indexer
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 10 hours ago
To fix the inefficiency, we replace the two operations on the dictionary (response.ContainsKey("active") and then accessing the value via response["active"]) in the test method with a single TryGetValue call. Specifically, we should use:
Assert.True(response.TryGetValue("active", out JsonElement activeValue));
Assert.False(activeValue.GetBoolean());This maintains the two assertions, but avoids the double lookup, combining them for efficiency. No other changes to functionality are needed. The only change is in the assertion region (lines 141 and 142) of the file test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/TokenIntrospectionTests.cs. All necessary types are already imported, so no imports or additional methods are required.
-
Copy modified lines R141-R142
| @@ -138,8 +138,8 @@ | ||
| var response = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(responseJson); | ||
|
|
||
| // Verify inactive status | ||
| Assert.True(response.ContainsKey("active")); | ||
| Assert.False(response["active"].GetBoolean()); | ||
| Assert.True(response.TryGetValue("active", out JsonElement activeValue)); | ||
| Assert.False(activeValue.GetBoolean()); | ||
|
|
||
| // Per RFC 7662 section 2.2: "If the introspection call is properly authorized | ||
| // but the token is not active, the authorization server MUST return ... {"active": false}" |
Description
Implements RFC 7662 token introspection endpoint at /connect/introspect for SMART on FHIR server with swapple support for the introspection endpoint for alternate SMART configurations.
Key Features:
Related issues
Addresses AB#174822
Testing
Test Coverage:
FHIR Team Checklist
Semver Change (docs)
Patch|Skip|Feature|Breaking (reason)