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); + } + } +}