diff --git a/build/jobs/e2e-tests.yml b/build/jobs/e2e-tests.yml
index 40f455c58c..448a274f71 100644
--- a/build/jobs/e2e-tests.yml
+++ b/build/jobs/e2e-tests.yml
@@ -60,6 +60,8 @@ steps:
'app_globalReaderUserApp_secret': $(app_globalReaderUserApp_secret)
'app_globalWriterUserApp_id': $(app_globalWriterUserApp_id)
'app_globalWriterUserApp_secret': $(app_globalWriterUserApp_secret)
+ 'app_smartUserClient_id': $(app_smartUserClient_id)
+ 'app_smartUserClient_secret': $(app_smartUserClient_secret)
'AZURESUBSCRIPTION_CLIENT_ID': $(AzurePipelinesCredential_ClientId)
'AZURESUBSCRIPTION_TENANT_ID': $(AZURESUBSCRIPTION_TENANT_ID)
'AZURESUBSCRIPTION_SERVICE_CONNECTION_ID': $(AZURESUBSCRIPTION_SERVICE_CONNECTION_ID)
diff --git a/build/jobs/provision-deploy.yml b/build/jobs/provision-deploy.yml
index 1ce8b0bcef..a73d41fb42 100644
--- a/build/jobs/provision-deploy.yml
+++ b/build/jobs/provision-deploy.yml
@@ -80,7 +80,7 @@ jobs:
numberOfInstances = 2
serviceName = $webAppName
keyVaultName = "${{ parameters.keyVaultName }}".ToLower()
- securityAuthenticationAuthority = "https://login.microsoftonline.com/$(tenant-id)"
+ securityAuthenticationAuthority = "https://sts.windows.net/$(tenant-id-guid)"
securityAuthenticationAudience = "${{ parameters.testEnvironmentUrl }}"
additionalFhirServerConfigProperties = $additionalProperties
enableAadSmartOnFhirProxy = $true
diff --git a/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs b/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs
index 3cf5f8f342..2f5e42ffda 100644
--- a/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs
+++ b/src/Microsoft.Health.Fhir.Api.OpenIddict/Extensions/DevelopmentIdentityProviderRegistrationExtensions.cs
@@ -97,6 +97,8 @@ public static IServiceCollection AddDevelopmentIdentityProvider(this IServiceCol
"/AadSmartOnFhirProxy/token");
options.SetAuthorizationEndpointUris("/AadSmartOnFhirProxy/authorize");
+ // Note: Introspection endpoint is handled by TokenIntrospectionController, not OpenIddict
+
// Dev flows:
options.AllowAuthorizationCodeFlow();
options.AllowClientCredentialsFlow();
diff --git a/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateFormatParametersAttribute.cs b/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateFormatParametersAttribute.cs
index e9af338d63..eaa9c6f769 100644
--- a/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateFormatParametersAttribute.cs
+++ b/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateFormatParametersAttribute.cs
@@ -38,40 +38,48 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context
HttpContext httpContext = context.HttpContext;
- _parametersValidator.CheckPrettyParameter(httpContext);
- _parametersValidator.CheckSummaryParameter(httpContext);
- _parametersValidator.CheckElementsParameter(httpContext);
- await _parametersValidator.CheckRequestedContentTypeAsync(httpContext);
-
- // If the request is a put or post and has a content-type, check that it's supported
- if (httpContext.Request.Method.Equals(HttpMethod.Post.Method, StringComparison.OrdinalIgnoreCase) ||
- httpContext.Request.Method.Equals(HttpMethod.Put.Method, StringComparison.OrdinalIgnoreCase))
+ if (!ShouldIgnoreValidation(httpContext))
{
- if (httpContext.Request.Headers.TryGetValue(HeaderNames.ContentType, out StringValues headerValue))
+ _parametersValidator.CheckPrettyParameter(httpContext);
+ _parametersValidator.CheckSummaryParameter(httpContext);
+ _parametersValidator.CheckElementsParameter(httpContext);
+ await _parametersValidator.CheckRequestedContentTypeAsync(httpContext);
+
+ // If the request is a put or post and has a content-type, check that it's supported
+ if (httpContext.Request.Method.Equals(HttpMethod.Post.Method, StringComparison.OrdinalIgnoreCase) ||
+ httpContext.Request.Method.Equals(HttpMethod.Put.Method, StringComparison.OrdinalIgnoreCase))
{
- if (!await _parametersValidator.IsFormatSupportedAsync(headerValue[0]))
+ if (httpContext.Request.Headers.TryGetValue(HeaderNames.ContentType, out StringValues headerValue))
+ {
+ if (!await _parametersValidator.IsFormatSupportedAsync(headerValue[0]))
+ {
+ throw new UnsupportedMediaTypeException(string.Format(Api.Resources.UnsupportedHeaderValue, headerValue.FirstOrDefault(), HeaderNames.ContentType));
+ }
+ }
+ else
{
- throw new UnsupportedMediaTypeException(string.Format(Api.Resources.UnsupportedHeaderValue, headerValue.FirstOrDefault(), HeaderNames.ContentType));
+ // If no content type is supplied, then the server should respond with an unsupported media type exception.
+ throw new UnsupportedMediaTypeException(Api.Resources.ContentTypeHeaderRequired);
}
}
- else
+ else if (httpContext.Request.Method.Equals(HttpMethod.Patch.Method, StringComparison.OrdinalIgnoreCase))
{
- // If no content type is supplied, then the server should respond with an unsupported media type exception.
- throw new UnsupportedMediaTypeException(Api.Resources.ContentTypeHeaderRequired);
- }
- }
- else if (httpContext.Request.Method.Equals(HttpMethod.Patch.Method, StringComparison.OrdinalIgnoreCase))
- {
- if (httpContext.Request.Headers.TryGetValue(HeaderNames.ContentType, out StringValues headerValue))
- {
- if (!await _parametersValidator.IsPatchFormatSupportedAsync(headerValue[0]))
+ if (httpContext.Request.Headers.TryGetValue(HeaderNames.ContentType, out StringValues headerValue))
{
- throw new UnsupportedMediaTypeException(string.Format(Api.Resources.UnsupportedHeaderValue, headerValue.FirstOrDefault(), HeaderNames.ContentType));
+ if (!await _parametersValidator.IsPatchFormatSupportedAsync(headerValue[0]))
+ {
+ throw new UnsupportedMediaTypeException(string.Format(Api.Resources.UnsupportedHeaderValue, headerValue.FirstOrDefault(), HeaderNames.ContentType));
+ }
}
}
}
await base.OnActionExecutionAsync(context, next);
}
+
+ private static bool ShouldIgnoreValidation(HttpContext httpContext)
+ {
+ return httpContext.Request.Path.StartsWithSegments("/CustomError", StringComparison.OrdinalIgnoreCase);
+ }
}
}
diff --git a/src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs b/src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
new file mode 100644
index 0000000000..4247504ef7
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
@@ -0,0 +1,317 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Net.Http;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using EnsureThat;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Health.Fhir.Core.Configs;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.Health.Fhir.Api.Features.Security
+{
+ ///
+ /// Default implementation of token introspection.
+ ///
+ public class DefaultTokenIntrospectionService : ITokenIntrospectionService
+ {
+ ///
+ /// Named HttpClient for retrieving OIDC configuration documents.
+ ///
+ public const string OidcConfigurationHttpClientName = "OidcConfiguration";
+
+ private readonly SecurityConfiguration _securityConfiguration;
+ private readonly JwtSecurityTokenHandler _tokenHandler;
+ private readonly ILogger _logger;
+ private readonly ConfigurationManager _configurationManager;
+
+ public DefaultTokenIntrospectionService(
+ IOptions securityConfiguration,
+ ILogger logger,
+ IHttpClientFactory httpClientFactory)
+ {
+ EnsureArg.IsNotNull(securityConfiguration, nameof(securityConfiguration));
+ EnsureArg.IsNotNull(logger, nameof(logger));
+ EnsureArg.IsNotNull(httpClientFactory, nameof(httpClientFactory));
+
+ _securityConfiguration = securityConfiguration.Value;
+ _tokenHandler = new JwtSecurityTokenHandler();
+ _logger = logger;
+
+ // Initialize configuration manager with named HttpClient
+ // Named HttpClient is designed for long-lived use and is managed by IHttpClientFactory
+ var authority = _securityConfiguration.Authentication.Authority.TrimEnd('/');
+ var httpClient = httpClientFactory.CreateClient(OidcConfigurationHttpClientName);
+
+ _configurationManager = new ConfigurationManager(
+ $"{authority}/.well-known/openid-configuration",
+ new OpenIdConnectConfigurationRetriever(),
+ new HttpDocumentRetriever(httpClient));
+ }
+
+ ///
+ /// Gets the security configuration for this service.
+ ///
+ protected SecurityConfiguration SecurityConfiguration => _securityConfiguration;
+
+ ///
+ /// Gets the JWT token handler for parsing and validating tokens.
+ ///
+ protected JwtSecurityTokenHandler TokenHandler => _tokenHandler;
+
+ ///
+ public async Task> IntrospectTokenAsync(string token, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // Attempt to validate the token
+ var validationResult = await ValidateTokenAsync(token, cancellationToken);
+
+ if (validationResult.IsValid)
+ {
+ // Build active response with claims
+ var response = BuildActiveResponse(validationResult.Token, validationResult.Principal);
+ _logger.LogInformation("Token introspection successful for token with sub: {Subject}", validationResult.Principal.FindFirst("sub")?.Value);
+ return response;
+ }
+ else
+ {
+ // Return inactive response for invalid tokens
+ _logger.LogInformation("Token introspection returned inactive: {Reason}", validationResult.Reason);
+ return BuildInactiveResponse();
+ }
+ }
+ catch (Exception ex)
+ {
+ // Never reveal why a token is invalid per RFC 7662 security guidance
+ _logger.LogWarning(ex, "Token introspection failed with exception");
+ return BuildInactiveResponse();
+ }
+ }
+
+ ///
+ /// Validates a JWT token using configured validation parameters.
+ ///
+ protected virtual async Task ValidateTokenAsync(string token, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ // First, try to parse the token to extract basic info
+ JwtSecurityToken jwtToken;
+ try
+ {
+ jwtToken = TokenHandler.ReadJwtToken(token);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to parse JWT token");
+ return TokenValidationResult.Invalid("malformed_token");
+ }
+
+ // Check if token is expired (quick check before full validation)
+ if (jwtToken.ValidTo < DateTime.UtcNow)
+ {
+ _logger.LogDebug("Token expired at {ExpirationTime}", jwtToken.ValidTo);
+ return TokenValidationResult.Invalid("expired");
+ }
+
+ // Build validation parameters
+ var validationParameters = await GetTokenValidationParametersAsync(cancellationToken);
+
+ // Validate token signature and claims
+ var principal = TokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
+
+ return TokenValidationResult.Valid(jwtToken, principal);
+ }
+ catch (SecurityTokenExpiredException ex)
+ {
+ _logger.LogInformation(ex, "Token validation failed: expired");
+ return TokenValidationResult.Invalid("expired");
+ }
+ catch (SecurityTokenException ex)
+ {
+ _logger.LogInformation(ex, "Token validation failed: security token exception");
+ return TokenValidationResult.Invalid("invalid");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Token validation failed with unexpected exception");
+ return TokenValidationResult.Invalid("error");
+ }
+ }
+
+ ///
+ /// Builds TokenValidationParameters from SecurityConfiguration.
+ ///
+ protected virtual async Task GetTokenValidationParametersAsync(CancellationToken cancellationToken = default)
+ {
+ var authority = SecurityConfiguration.Authentication.Authority;
+ var audience = SecurityConfiguration.Authentication.Audience;
+
+ // Normalize authority to ensure consistent JWKS endpoint
+ var normalizedAuthority = authority.TrimEnd('/');
+
+ // Pre-fetch the OpenID Connect configuration
+ var config = await _configurationManager.GetConfigurationAsync(cancellationToken);
+
+ return new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+
+ // Accept issuer with or without trailing slash (common OpenIddict variation)
+ ValidIssuers = new[] { normalizedAuthority, normalizedAuthority + "/" },
+ ValidateAudience = true,
+ ValidAudience = audience,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKeys = config.SigningKeys,
+ ClockSkew = TimeSpan.FromMinutes(5), // Allow 5 minutes clock skew
+ };
+ }
+
+ ///
+ /// Builds an RFC 7662 compliant active token response.
+ ///
+ protected Dictionary BuildActiveResponse(JwtSecurityToken token, ClaimsPrincipal principal)
+ {
+ var response = new Dictionary
+ {
+ ["active"] = true,
+ ["token_type"] = "Bearer",
+ };
+
+ // Extract standard claims
+ AddClaimIfPresent(response, "sub", principal);
+ AddClaimIfPresent(response, "iss", principal);
+ AddClaimIfPresent(response, "aud", principal);
+
+ // Add exp and iat as Unix timestamps
+ if (token.ValidTo != DateTime.MinValue)
+ {
+ response["exp"] = new DateTimeOffset(token.ValidTo).ToUnixTimeSeconds();
+ }
+
+ if (token.ValidFrom != DateTime.MinValue)
+ {
+ response["iat"] = new DateTimeOffset(token.ValidFrom).ToUnixTimeSeconds();
+ }
+
+ // Extract client_id (use sub if client_id not present)
+ var clientId = principal.FindFirst("client_id")?.Value ?? principal.FindFirst("sub")?.Value;
+ if (!string.IsNullOrEmpty(clientId))
+ {
+ response["client_id"] = clientId;
+ }
+
+ // Extract username from name claim
+ AddClaimIfPresent(response, "username", principal, "name");
+
+ // Extract scope - check for raw_scope first (SMART v2), then scope, then scp
+ var scope = principal.FindFirst("raw_scope")?.Value
+ ?? principal.FindFirst("scope")?.Value
+ ?? GetScopeFromMultipleClaims(principal);
+
+ if (!string.IsNullOrEmpty(scope))
+ {
+ response["scope"] = scope;
+ }
+
+ // Add SMART-specific claims
+ AddClaimIfPresent(response, "patient", principal);
+ AddClaimIfPresent(response, "fhirUser", principal);
+
+ return response;
+ }
+
+ ///
+ /// Builds an RFC 7662 compliant inactive token response.
+ ///
+ protected static Dictionary BuildInactiveResponse()
+ {
+ return new Dictionary
+ {
+ ["active"] = false,
+ };
+ }
+
+ ///
+ /// Adds a claim to the response if present in the principal.
+ ///
+ private static void AddClaimIfPresent(
+ Dictionary response,
+ string key,
+ ClaimsPrincipal principal,
+ string claimType = null)
+ {
+ claimType ??= key;
+ var claim = principal.FindFirst(claimType);
+ if (claim != null && !string.IsNullOrEmpty(claim.Value))
+ {
+ response[key] = claim.Value;
+ }
+ }
+
+ ///
+ /// Gets scope from multiple scope claims (scp claim pattern).
+ ///
+ protected string GetScopeFromMultipleClaims(ClaimsPrincipal principal)
+ {
+ // Check all configured scope claim names
+ var scopeClaimNames = SecurityConfiguration.Authorization.ScopesClaim ?? new List { "scp" };
+
+ // Find the first claim name that has associated claims
+ var scopeClaims = scopeClaimNames
+ .Select(claimName => principal.FindAll(claimName))
+ .FirstOrDefault(claims => claims.Any());
+
+ // Join multiple scope claims with space separator
+ return scopeClaims != null
+ ? string.Join(" ", scopeClaims.Select(c => c.Value))
+ : null;
+ }
+
+ ///
+ /// Result of token validation.
+ ///
+ protected class TokenValidationResult
+ {
+ public bool IsValid { get; private set; }
+
+ public JwtSecurityToken Token { get; private set; }
+
+ public ClaimsPrincipal Principal { get; private set; }
+
+ public string Reason { get; private set; }
+
+ public static TokenValidationResult Valid(JwtSecurityToken token, ClaimsPrincipal principal)
+ {
+ return new TokenValidationResult
+ {
+ IsValid = true,
+ Token = token,
+ Principal = principal,
+ };
+ }
+
+ public static TokenValidationResult Invalid(string reason)
+ {
+ return new TokenValidationResult
+ {
+ IsValid = false,
+ Reason = reason,
+ };
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Api/Features/Security/ITokenIntrospectionService.cs b/src/Microsoft.Health.Fhir.Api/Features/Security/ITokenIntrospectionService.cs
new file mode 100644
index 0000000000..875e076606
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Api/Features/Security/ITokenIntrospectionService.cs
@@ -0,0 +1,28 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Health.Fhir.Api.Features.Security
+{
+ ///
+ /// Service for performing token introspection per RFC 7662.
+ ///
+ public interface ITokenIntrospectionService
+ {
+ ///
+ /// Introspects a token and returns the introspection response.
+ ///
+ /// The token to introspect.
+ /// The cancellation token.
+ ///
+ /// Dictionary containing introspection response with 'active' key and optional claims.
+ /// Returns {"active": false} for invalid tokens.
+ ///
+ Task> IntrospectTokenAsync(string token, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Api/Modules/SecurityModule.cs b/src/Microsoft.Health.Fhir.Api/Modules/SecurityModule.cs
index 8ad767e1b8..ba6b2a283b 100644
--- a/src/Microsoft.Health.Fhir.Api/Modules/SecurityModule.cs
+++ b/src/Microsoft.Health.Fhir.Api/Modules/SecurityModule.cs
@@ -12,6 +12,7 @@
using Microsoft.Health.Extensions.DependencyInjection;
using Microsoft.Health.Fhir.Api.Configs;
using Microsoft.Health.Fhir.Api.Features.Bundle;
+using Microsoft.Health.Fhir.Api.Features.Security;
using Microsoft.Health.Fhir.Core.Configs;
using Microsoft.Health.Fhir.Core.Features.Security;
using Microsoft.Health.Fhir.Core.Features.Security.Authorization;
@@ -35,6 +36,13 @@ public void Load(IServiceCollection services)
services.AddSingleton();
+ // Register named HttpClient for OIDC configuration retrieval
+ // This client is used by ConfigurationManager to fetch .well-known/openid-configuration
+ services.AddHttpClient(DefaultTokenIntrospectionService.OidcConfigurationHttpClientName);
+
+ // Register token introspection service
+ services.AddSingleton();
+
// Set the token handler to not do auto inbound mapping. (e.g. "roles" -> "http://schemas.microsoft.com/ws/2008/06/identity/claims/role")
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs
new file mode 100644
index 0000000000..60c67515eb
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/TokenIntrospectionControllerTests.cs
@@ -0,0 +1,306 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Health.Fhir.Api.Controllers;
+using Microsoft.Health.Fhir.Api.Features.Security;
+using Microsoft.Health.Fhir.Tests.Common;
+using Microsoft.Health.Test.Utilities;
+using NSubstitute;
+using Xunit;
+
+namespace Microsoft.Health.Fhir.Api.UnitTests.Controllers
+{
+ [Trait(Traits.OwningTeam, OwningTeam.Fhir)]
+ [Trait(Traits.Category, Categories.SmartOnFhir)]
+ public class TokenIntrospectionControllerTests
+ {
+ private readonly TokenIntrospectionController _controller;
+ private readonly ITokenIntrospectionService _introspectionService;
+
+ public TokenIntrospectionControllerTests()
+ {
+ _introspectionService = Substitute.For();
+
+ _controller = new TokenIntrospectionController(
+ _introspectionService,
+ NullLogger.Instance);
+
+ // Set up ControllerContext with HttpContext
+ _controller.ControllerContext = new ControllerContext(
+ new ActionContext(
+ new DefaultHttpContext(),
+ new RouteData(),
+ new ControllerActionDescriptor()));
+ }
+
+ [Fact]
+ public async Task GivenMissingTokenParameter_WhenIntrospect_ThenReturnsBadRequest()
+ {
+ // Act
+ var result = await _controller.Introspect(token: null);
+
+ // Assert
+ var badRequestResult = Assert.IsType(result);
+ Assert.NotNull(badRequestResult.Value);
+ }
+
+ [Fact]
+ public async Task GivenEmptyTokenParameter_WhenIntrospect_ThenReturnsBadRequest()
+ {
+ // Act
+ var result = await _controller.Introspect(token: string.Empty);
+
+ // Assert
+ var badRequestResult = Assert.IsType(result);
+ Assert.NotNull(badRequestResult.Value);
+ }
+
+ [Fact]
+ public async Task GivenWhitespaceTokenParameter_WhenIntrospect_ThenReturnsBadRequest()
+ {
+ // Act
+ var result = await _controller.Introspect(token: " ");
+
+ // Assert
+ var badRequestResult = Assert.IsType(result);
+ Assert.NotNull(badRequestResult.Value);
+ }
+
+ [Fact]
+ public async Task GivenExpiredToken_WhenIntrospect_ThenReturnsInactive()
+ {
+ // Arrange
+ var expiredToken = "expired.jwt.token";
+ _introspectionService.IntrospectTokenAsync(expiredToken, Arg.Any())
+ .Returns(new Dictionary { { "active", false } });
+
+ // Act
+ var result = await _controller.Introspect(expiredToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.TryGetValue("active", out var active));
+ Assert.False((bool)active);
+ Assert.Single(response);
+ }
+
+ [Fact]
+ public async Task GivenMalformedToken_WhenIntrospect_ThenReturnsInactive()
+ {
+ // Arrange
+ var malformedToken = "not.a.valid.jwt.token";
+ _introspectionService.IntrospectTokenAsync(malformedToken, Arg.Any())
+ .Returns(new Dictionary { { "active", false } });
+
+ // Act
+ var result = await _controller.Introspect(malformedToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.TryGetValue("active", out var active));
+ Assert.False((bool)active);
+ Assert.Single(response);
+ }
+
+ [Fact]
+ public async Task GivenInvalidSignatureToken_WhenIntrospect_ThenReturnsInactive()
+ {
+ // Arrange
+ var invalidToken = "invalid.signature.token";
+ _introspectionService.IntrospectTokenAsync(invalidToken, Arg.Any())
+ .Returns(new Dictionary { { "active", false } });
+
+ // Act
+ var result = await _controller.Introspect(invalidToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.TryGetValue("active", out var active));
+ Assert.False((bool)active);
+ }
+
+ [Fact]
+ public async Task GivenTokenWithStandardClaims_WhenIntrospect_ThenReturnsActiveWithClaims()
+ {
+ // Arrange
+ var validToken = "valid.jwt.token";
+ var expectedResponse = new Dictionary
+ {
+ { "active", true },
+ { "sub", "test-user-123" },
+ { "client_id", "test-client" },
+ { "scope", "patient/Patient.read patient/Observation.read" },
+ };
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(expectedResponse);
+
+ // Act
+ var result = await _controller.Introspect(validToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.ContainsKey("active"));
+ Assert.True((bool)response["active"]);
+ Assert.Equal("test-user-123", response["sub"]);
+ Assert.Equal("test-client", response["client_id"]);
+ }
+
+ [Fact]
+ public async Task GivenTokenWithSmartClaims_WhenIntrospect_ThenReturnsActiveWithSmartClaims()
+ {
+ // Arrange
+ var validToken = "valid.smart.token";
+ var expectedResponse = new Dictionary
+ {
+ { "active", true },
+ { "sub", "test-user-123" },
+ { "scope", "patient/Patient.read launch/patient openid fhirUser" },
+ { "patient", "Patient/test-patient-456" },
+ { "fhirUser", "https://fhir-server.com/Practitioner/test-practitioner-789" },
+ };
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(expectedResponse);
+
+ // Act
+ var result = await _controller.Introspect(validToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.ContainsKey("active"));
+ Assert.True((bool)response["active"]);
+ Assert.Equal("Patient/test-patient-456", response["patient"]);
+ Assert.Equal("https://fhir-server.com/Practitioner/test-practitioner-789", response["fhirUser"]);
+ }
+
+ [Fact]
+ public async Task GivenTokenWithRawScope_WhenIntrospect_ThenUsesRawScope()
+ {
+ // Arrange
+ var validToken = "valid.smart.v2.token";
+ var rawScope = "patient/Observation.rs?category=vital-signs patient/Patient.read";
+ var expectedResponse = new Dictionary
+ {
+ { "active", true },
+ { "sub", "test-user" },
+ { "scope", rawScope },
+ };
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(expectedResponse);
+
+ // Act
+ var result = await _controller.Introspect(validToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.ContainsKey("active"));
+ Assert.Equal(rawScope, response["scope"]);
+ }
+
+ [Fact]
+ public async Task GivenTokenWithMultipleScopeClaims_WhenIntrospect_ThenCombinesScopes()
+ {
+ // Arrange
+ var validToken = "valid.multi.scope.token";
+ var combinedScopes = "patient/Patient.read patient/Observation.read launch/patient";
+ var expectedResponse = new Dictionary
+ {
+ { "active", true },
+ { "sub", "test-user" },
+ { "scope", combinedScopes },
+ };
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(expectedResponse);
+
+ // Act
+ var result = await _controller.Introspect(validToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.ContainsKey("active"));
+ Assert.Equal(combinedScopes, response["scope"]);
+ }
+
+ [Fact]
+ public async Task GivenTokenWithExpAndIat_WhenIntrospect_ThenReturnsUnixTimestamps()
+ {
+ // Arrange
+ var validToken = "valid.token.with.timestamps";
+ var expectedResponse = new Dictionary
+ {
+ { "active", true },
+ { "sub", "test-user" },
+ { "exp", 1893456000L }, // Unix timestamp
+ { "iat", 1893452400L }, // Unix timestamp
+ };
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(expectedResponse);
+
+ // Act
+ var result = await _controller.Introspect(validToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.ContainsKey("active"));
+ Assert.True(response.ContainsKey("exp"));
+ Assert.True(response.ContainsKey("iat"));
+ }
+
+ [Fact]
+ public async Task GivenTokenWithOnlySubClaim_WhenIntrospect_ThenUsesSubAsClientId()
+ {
+ // Arrange
+ var validToken = "valid.token.sub.only";
+ var expectedResponse = new Dictionary
+ {
+ { "active", true },
+ { "sub", "test-client-app" },
+ { "client_id", "test-client-app" }, // sub used as client_id when not present
+ };
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(expectedResponse);
+
+ // Act
+ var result = await _controller.Introspect(validToken);
+
+ // Assert
+ var okResult = Assert.IsType(result);
+ var response = Assert.IsType>(okResult.Value);
+ Assert.True(response.ContainsKey("active"));
+ Assert.Equal("test-client-app", response["sub"]);
+ Assert.Equal("test-client-app", response["client_id"]);
+ }
+
+ [Fact]
+ public async Task GivenValidToken_WhenIntrospect_ThenCallsIntrospectionService()
+ {
+ // Arrange
+ var validToken = "test.token";
+ _introspectionService.IntrospectTokenAsync(validToken, Arg.Any())
+ .Returns(new Dictionary { { "active", true } });
+
+ // Act
+ await _controller.Introspect(validToken);
+
+ // Assert
+ await _introspectionService.Received(1).IntrospectTokenAsync(validToken, Arg.Any());
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems
index ad21a31ce1..bb72b22d9f 100644
--- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems
+++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Microsoft.Health.Fhir.Shared.Api.UnitTests.projitems
@@ -27,6 +27,7 @@
+
diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs
new file mode 100644
index 0000000000..9080599659
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/TokenIntrospectionController.cs
@@ -0,0 +1,63 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System.Threading.Tasks;
+using EnsureThat;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Microsoft.Health.Api.Features.Audit;
+using Microsoft.Health.Fhir.Api.Features.Filters;
+using Microsoft.Health.Fhir.Api.Features.Security;
+using Microsoft.Health.Fhir.ValueSets;
+
+namespace Microsoft.Health.Fhir.Api.Controllers
+{
+ ///
+ /// Controller implementing RFC 7662 token introspection endpoint.
+ /// Supports introspection for both development (OpenIddict) and production (external IdP) tokens.
+ ///
+ [ServiceFilter(typeof(AuditLoggingFilterAttribute))]
+ [ServiceFilter(typeof(OperationOutcomeExceptionFilterAttribute))]
+ [ValidateModelState]
+ public class TokenIntrospectionController : Controller
+ {
+ private readonly ITokenIntrospectionService _introspectionService;
+ private readonly ILogger _logger;
+
+ public TokenIntrospectionController(
+ ITokenIntrospectionService introspectionService,
+ ILogger logger)
+ {
+ EnsureArg.IsNotNull(introspectionService, nameof(introspectionService));
+ EnsureArg.IsNotNull(logger, nameof(logger));
+
+ _introspectionService = introspectionService;
+ _logger = logger;
+ }
+
+ ///
+ /// Token introspection endpoint per RFC 7662.
+ ///
+ /// The token to introspect.
+ /// Token introspection response with active status and claims.
+ [HttpPost]
+ [Route("/connect/introspect")]
+ [Consumes("application/x-www-form-urlencoded")]
+ [AuditEventType(AuditEventSubType.SmartOnFhirToken)]
+ public async Task Introspect([FromForm] string token)
+ {
+ // Validate token parameter is present
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ _logger.LogWarning("Token introspection request missing token parameter");
+ return BadRequest(new { error = "invalid_request", error_description = "token parameter is required" });
+ }
+
+ // Delegate to introspection service
+ var response = await _introspectionService.IntrospectTokenAsync(token, HttpContext.RequestAborted);
+ return Ok(response);
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems b/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems
index 3e4fcd81b9..39e6a85b6c 100644
--- a/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems
+++ b/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems
@@ -23,6 +23,7 @@
+
diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Microsoft.Health.Fhir.Shared.Tests.E2E.projitems b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Microsoft.Health.Fhir.Shared.Tests.E2E.projitems
index 71b8da03cd..c0886811b1 100644
--- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Microsoft.Health.Fhir.Shared.Tests.E2E.projitems
+++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Microsoft.Health.Fhir.Shared.Tests.E2E.projitems
@@ -95,6 +95,7 @@
+
diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/TokenIntrospectionTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/TokenIntrospectionTests.cs
new file mode 100644
index 0000000000..fbd6a129f0
--- /dev/null
+++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/TokenIntrospectionTests.cs
@@ -0,0 +1,322 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.Health.Fhir.Tests.Common.FixtureParameters;
+using Microsoft.Health.Fhir.Tests.E2E.Common;
+using Microsoft.Health.Fhir.Tests.E2E.Rest;
+using Xunit;
+
+namespace Microsoft.Health.Fhir.Smart.Tests.E2E
+{
+ ///
+ /// E2E tests for RFC 7662 Token Introspection endpoint using real OpenIddict tokens.
+ ///
+ [HttpIntegrationFixtureArgumentSets(DataStore.All, Format.Json)]
+ public class TokenIntrospectionTests : IClassFixture
+ {
+ private readonly HttpClient _httpClient;
+ private readonly HttpIntegrationTestFixture _fixture;
+ private readonly Uri _tokenUri;
+ private readonly Uri _introspectionUri;
+
+ public TokenIntrospectionTests(HttpIntegrationTestFixture fixture)
+ {
+ _fixture = fixture;
+ _httpClient = fixture.TestFhirClient.HttpClient;
+ _tokenUri = fixture.TestFhirServer.TokenUri;
+ _introspectionUri = new Uri(fixture.TestFhirServer.BaseAddress, "/connect/introspect");
+ }
+
+ [Fact]
+ public async Task GivenValidToken_WhenIntrospecting_ThenReturnsActiveWithStandardClaims()
+ {
+ // Arrange - Get a real access token from OpenIddict
+ var accessToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+
+ // Act - Introspect the token
+ var introspectionResponse = await IntrospectTokenAsync(accessToken, accessToken);
+
+ // Assert - Verify RFC 7662 compliance
+ Assert.Equal(HttpStatusCode.OK, introspectionResponse.StatusCode);
+
+ var responseJson = await introspectionResponse.Content.ReadAsStringAsync();
+ var response = JsonSerializer.Deserialize>(responseJson);
+
+ // Verify active status
+ Assert.True(response.TryGetValue("active", out JsonElement activeElement));
+ Assert.True(activeElement.GetBoolean());
+
+ // Verify token type
+ Assert.True(response.TryGetValue("token_type", out JsonElement tokenTypeElement));
+ Assert.Equal("Bearer", tokenTypeElement.GetString());
+
+ // Verify standard claims exist
+ Assert.True(response.ContainsKey("sub"));
+ Assert.True(response.ContainsKey("iss"));
+ Assert.True(response.ContainsKey("aud"));
+ Assert.True(response.TryGetValue("exp", out JsonElement expirationElement));
+ Assert.True(response.ContainsKey("client_id"));
+
+ // Verify timestamps are Unix timestamps (positive numbers)
+ Assert.True(expirationElement.GetInt64() > 0);
+ if (response.TryGetValue("iat", out JsonElement issuedAtElement))
+ {
+ Assert.True(issuedAtElement.GetInt64() > 0);
+ }
+ }
+
+ [Fact]
+ public async Task GivenValidToken_WhenIntrospecting_ThenReturnsScopeAsString()
+ {
+ // Arrange
+ var accessToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+
+ // Act
+ var introspectionResponse = await IntrospectTokenAsync(accessToken, accessToken);
+
+ // Assert
+ var responseJson = await introspectionResponse.Content.ReadAsStringAsync();
+ var response = JsonSerializer.Deserialize>(responseJson);
+
+ Assert.True(response["active"].GetBoolean());
+
+ // Scope should be a space-separated string, not an array
+ if (response.TryGetValue("scope", out JsonElement scopeElement))
+ {
+ Assert.Equal(JsonValueKind.String, scopeElement.ValueKind);
+ var scope = scopeElement.GetString();
+ Assert.NotEmpty(scope);
+ }
+ }
+
+ [Fact]
+ public async Task GivenTokenWithFhirUser_WhenIntrospecting_ThenReturnsSmartClaims()
+ {
+ // Arrange - Get token from a SMART user client
+ var accessToken = await GetAccessTokenAsync(TestApplications.SmartUserClient);
+
+ // Act
+ var introspectionResponse = await IntrospectTokenAsync(accessToken, accessToken);
+
+ // Assert
+ var responseJson = await introspectionResponse.Content.ReadAsStringAsync();
+ var response = JsonSerializer.Deserialize>(responseJson);
+
+ Assert.True(response["active"].GetBoolean());
+
+ // SMART clients should have fhirUser claim
+ if (response.TryGetValue("fhirUser", out JsonElement fhirUserElement))
+ {
+ var fhirUser = fhirUserElement.GetString();
+ Assert.NotEmpty(fhirUser);
+
+ // fhirUser should be a full URL to a Practitioner, Patient, Person, or RelatedPerson
+ Assert.Contains("http", fhirUser, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Fact]
+ public async Task GivenInvalidToken_WhenIntrospecting_ThenReturnsInactive()
+ {
+ // Arrange - Use an invalid token
+ var accessToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+ var invalidToken = "invalid.jwt.token";
+
+ // Act
+ var introspectionResponse = await IntrospectTokenAsync(accessToken, invalidToken);
+
+ // Assert - Should return 200 OK with active=false per RFC 7662
+ Assert.Equal(HttpStatusCode.OK, introspectionResponse.StatusCode);
+
+ var responseJson = await introspectionResponse.Content.ReadAsStringAsync();
+ var response = JsonSerializer.Deserialize>(responseJson);
+
+ // Verify inactive status
+ Assert.True(response.TryGetValue("active", out JsonElement inactiveElement));
+ Assert.False(inactiveElement.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}"
+ // No other fields should be present
+ Assert.Single(response);
+ }
+
+ [Fact]
+ public async Task GivenMalformedToken_WhenIntrospecting_ThenReturnsInactive()
+ {
+ // Arrange
+ var accessToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+ var malformedToken = "not-even-three-parts";
+
+ // Act
+ var introspectionResponse = await IntrospectTokenAsync(accessToken, malformedToken);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.OK, introspectionResponse.StatusCode);
+
+ var responseJson = await introspectionResponse.Content.ReadAsStringAsync();
+ var response = JsonSerializer.Deserialize>(responseJson);
+
+ Assert.True(response.TryGetValue("active", out JsonElement inactiveElement));
+ Assert.False(inactiveElement.GetBoolean());
+ Assert.Single(response); // Only 'active' field
+ }
+
+ [Fact]
+ public async Task GivenMissingTokenParameter_WhenIntrospecting_ThenReturnsBadRequest()
+ {
+ // Arrange
+ var accessToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+
+ // Act - Send request without token parameter
+ using var content = new FormUrlEncodedContent(new Dictionary());
+ using var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri)
+ {
+ Content = content,
+ };
+ request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
+
+ 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);
+ }
+
+ [Fact]
+ public async Task GivenNoAuthentication_WhenIntrospecting_ThenReturnsUnauthorized()
+ {
+ // Arrange - Get a token to introspect
+ var someToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+
+ // Act - Send request with NO authentication header (completely unauthenticated)
+ using var content = new FormUrlEncodedContent(new Dictionary
+ {
+ { "token", someToken },
+ });
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri)
+ {
+ Content = content,
+ };
+
+ // Create an unauthenticated client using the test infrastructure's message handler
+ // This ensures requests are properly routed to the in-process test server without auth
+ using var unauthenticatedHandler = new TestAuthenticationHttpMessageHandler(null)
+ {
+ InnerHandler = _fixture.TestFhirServer.CreateMessageHandler(),
+ };
+ using var unauthenticatedClient = new HttpClient(unauthenticatedHandler) { BaseAddress = _fixture.TestFhirServer.BaseAddress };
+ var introspectionResponse = await unauthenticatedClient.SendAsync(request);
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Unauthorized, introspectionResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task GivenMultipleValidTokens_WhenIntrospecting_ThenEachReturnsCorrectClaims()
+ {
+ // Arrange - Get tokens from different applications
+ var adminToken = await GetAccessTokenAsync(TestApplications.GlobalAdminServicePrincipal);
+ var readerToken = await GetAccessTokenAsync(TestApplications.ReadOnlyUser);
+
+ // Act - Introspect both tokens
+ var adminIntrospection = await IntrospectTokenAsync(adminToken, adminToken);
+ var readerIntrospection = await IntrospectTokenAsync(readerToken, readerToken);
+
+ // Assert - Both should be active
+ var adminResponse = JsonSerializer.Deserialize>(
+ await adminIntrospection.Content.ReadAsStringAsync());
+ var readerResponse = JsonSerializer.Deserialize>(
+ await readerIntrospection.Content.ReadAsStringAsync());
+
+ Assert.True(adminResponse["active"].GetBoolean());
+ Assert.True(readerResponse["active"].GetBoolean());
+
+ // Verify different client_ids
+ var adminClientId = adminResponse["client_id"].GetString();
+ var readerClientId = readerResponse["client_id"].GetString();
+ Assert.NotEqual(adminClientId, readerClientId);
+ }
+
+ ///
+ /// Helper method to get an access token from OpenIddict token endpoint.
+ ///
+ private async Task GetAccessTokenAsync(TestApplication testApplication)
+ {
+ var tokenRequest = BuildTokenRequest(testApplication);
+
+ using var content = new FormUrlEncodedContent(tokenRequest);
+
+ var response = await _httpClient.PostAsync(_tokenUri, content);
+ response.EnsureSuccessStatusCode();
+
+ var responseJson = await response.Content.ReadAsStringAsync();
+ var tokenResponse = JsonSerializer.Deserialize>(responseJson);
+
+ return tokenResponse["access_token"].GetString();
+ }
+
+ private static IDictionary BuildTokenRequest(TestApplication testApplication)
+ {
+ var (scope, resource) = ResolveAudienceParameters(testApplication);
+
+ var request = new Dictionary
+ {
+ { "grant_type", testApplication.GrantType },
+ { "client_id", testApplication.ClientId },
+ { "client_secret", testApplication.ClientSecret },
+ };
+
+ if (!string.IsNullOrWhiteSpace(scope))
+ {
+ request["scope"] = scope;
+ }
+
+ if (!string.IsNullOrWhiteSpace(resource))
+ {
+ request["resource"] = resource;
+ }
+
+ return request;
+ }
+
+ private static (string Scope, string Resource) ResolveAudienceParameters(TestApplication testApplication)
+ {
+ bool isWrongAudienceClient = testApplication.Equals(TestApplications.WrongAudienceClient);
+
+ string scope = isWrongAudienceClient ? testApplication.ClientId : AuthenticationSettings.Scope;
+ string resource = isWrongAudienceClient ? testApplication.ClientId : AuthenticationSettings.Resource;
+
+ return (scope, resource);
+ }
+
+ ///
+ /// Helper method to introspect a token using the introspection endpoint.
+ ///
+ private async Task IntrospectTokenAsync(string authToken, string tokenToIntrospect)
+ {
+ using var content = new FormUrlEncodedContent(new Dictionary
+ {
+ { "token", tokenToIntrospect },
+ });
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, _introspectionUri)
+ {
+ Content = content,
+ };
+
+ return await _httpClient.SendAsync(request);
+ }
+ }
+}