Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a23bfe0
Add token introspection endpoint and related tests
mikaelweave Dec 3, 2025
fcf508b
Refactor token introspection endpoint handling and add audit event type
mikaelweave Dec 3, 2025
6410e8f
Implement token introspection service and update controller to use it
mikaelweave Dec 5, 2025
e005e0a
Refactor DefaultTokenIntrospectionService to enhance extensibility an…
mikaelweave Dec 5, 2025
afb3e4c
Refactor scope claim retrieval for improved readability and efficiency
mikaelweave Dec 5, 2025
b7b2d53
Refactor TokenIntrospectionControllerTests to improve resource manage…
mikaelweave Dec 5, 2025
a9d08c7
Add TokenIntrospectionTests for RFC 7662 compliance and standard clai…
mikaelweave Dec 5, 2025
fc13cc7
Merge branch 'main' into personal/mikaelw/smart-token-introspection-e…
mikaelweave Dec 5, 2025
1e03cac
Add Token Introspection Tests for RFC 7662 compliance and remove lega…
mikaelweave Dec 6, 2025
2d77cc8
Refactor token introspection service and tests
mikaelweave Dec 8, 2025
f977da1
Remove test for unsupported content type in introspection
mikaelweave Dec 8, 2025
0b6cd2b
Refactor validation logic and update introspection auth
mikaelweave Dec 8, 2025
5b23a53
Add smart user client credentials to E2E test variables and refactor …
mikaelweave Dec 8, 2025
4931b4d
Refactor token request handling in Token Introspection tests for impr…
mikaelweave Dec 9, 2025
5e497a5
Potential fix for code scanning alert no. 2964: Missing Dispose call …
mikaelweave Dec 9, 2025
a6c28ed
Refactor JSON access in TokenIntrospectionTests
mikaelweave Dec 9, 2025
f5e7d63
Refactor TokenIntrospectionControllerTests HttpClient usage
mikaelweave Dec 9, 2025
9567a41
Fix build issues introduced by codeql
mikaelweave Dec 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
// -------------------------------------------------------------------------------------------------
// 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.Security.Claims;
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
{
/// <summary>
/// Default implementation of token introspection for OSS (single authority/audience).
/// PaaS can provide alternative implementation supporting multiple authorities.
/// </summary>
public class DefaultTokenIntrospectionService : ITokenIntrospectionService
{
private readonly SecurityConfiguration _securityConfiguration;
private readonly JwtSecurityTokenHandler _tokenHandler;
private readonly ILogger<DefaultTokenIntrospectionService> _logger;

public DefaultTokenIntrospectionService(
IOptions<SecurityConfiguration> securityConfiguration,
ILogger<DefaultTokenIntrospectionService> logger)
{
EnsureArg.IsNotNull(securityConfiguration, nameof(securityConfiguration));
EnsureArg.IsNotNull(logger, nameof(logger));

_securityConfiguration = securityConfiguration.Value;
_tokenHandler = new JwtSecurityTokenHandler();
_logger = logger;
}

/// <inheritdoc />
public Dictionary<string, object> IntrospectToken(string token)
{
try
{
// Attempt to validate the token
var validationResult = ValidateToken(token);

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.LogDebug("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();
}
}

/// <summary>
/// Validates a JWT token using configured validation parameters.
/// </summary>
private TokenValidationResult ValidateToken(string token)
{
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 = GetTokenValidationParameters();

// Validate token signature and claims
var principal = _tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);

return TokenValidationResult.Valid(jwtToken, principal);
}
catch (SecurityTokenExpiredException ex)
{
_logger.LogDebug(ex, "Token validation failed: expired");
return TokenValidationResult.Invalid("expired");
}
catch (SecurityTokenException ex)
{
_logger.LogDebug(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");
}
}

/// <summary>
/// Builds TokenValidationParameters from SecurityConfiguration.
/// </summary>
private TokenValidationParameters GetTokenValidationParameters()
{
var authority = _securityConfiguration.Authentication.Authority;
var audience = _securityConfiguration.Authentication.Audience;

// Normalize authority to ensure consistent JWKS endpoint
var normalizedAuthority = authority.TrimEnd('/');

// Configure OpenID Connect configuration retriever for JWKS
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{normalizedAuthority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever());

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,
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{
// Retrieve signing keys from OpenID Connect configuration
var config = configurationManager.GetConfigurationAsync().GetAwaiter().GetResult();
return config.SigningKeys;
},
ClockSkew = TimeSpan.FromMinutes(5), // Allow 5 minutes clock skew
};
}

/// <summary>
/// Builds an RFC 7662 compliant active token response.
/// </summary>
private Dictionary<string, object> BuildActiveResponse(JwtSecurityToken token, ClaimsPrincipal principal)
{
var response = new Dictionary<string, object>
{
["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;
}

/// <summary>
/// Builds an RFC 7662 compliant inactive token response.
/// </summary>
private static Dictionary<string, object> BuildInactiveResponse()
{
return new Dictionary<string, object>
{
["active"] = false,
};
}

/// <summary>
/// Adds a claim to the response if present in the principal.
/// </summary>
private static void AddClaimIfPresent(
Dictionary<string, object> 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;
}
}

/// <summary>
/// Gets scope from multiple scope claims (scp claim pattern).
/// </summary>
private string GetScopeFromMultipleClaims(ClaimsPrincipal principal)
{
// Check all configured scope claim names
var scopeClaimNames = _securityConfiguration.Authorization.ScopesClaim ?? new List<string> { "scp" };

foreach (var claimName in scopeClaimNames)
{
var scopeClaims = principal.FindAll(claimName).ToList();
if (scopeClaims.Any())
{
// Join multiple scope claims with space separator
return string.Join(" ", scopeClaims.Select(c => c.Value));
}
}

return null;
}

/// <summary>
/// Result of token validation.
/// </summary>
private 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,
};
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// -------------------------------------------------------------------------------------------------
// 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;

namespace Microsoft.Health.Fhir.Api.Features.Security
{
/// <summary>
/// Service for performing token introspection per RFC 7662.
/// Allows different implementations for OSS (single authority) vs PaaS (multi-tenant).
/// </summary>
public interface ITokenIntrospectionService
{
/// <summary>
/// Introspects a token and returns the introspection response.
/// </summary>
/// <param name="token">The token to introspect.</param>
/// <returns>
/// Dictionary containing introspection response with 'active' key and optional claims.
/// Returns {"active": false} for invalid tokens.
/// </returns>
Dictionary<string, object> IntrospectToken(string token);
}
}
4 changes: 4 additions & 0 deletions src/Microsoft.Health.Fhir.Api/Modules/SecurityModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +36,9 @@ public void Load(IServiceCollection services)

services.AddSingleton<IBundleHttpContextAccessor, BundleHttpContextAccessor>();

// Register token introspection service (PaaS can provide multi-tenant implementation)
services.AddSingleton<ITokenIntrospectionService, DefaultTokenIntrospectionService>();

// 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();

Expand Down
Loading
Loading