-
Notifications
You must be signed in to change notification settings - Fork 568
SMART on FHIR Token Introspection Endpoint #5257
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mikaelweave
wants to merge
14
commits into
main
Choose a base branch
from
personal/mikaelw/smart-token-introspection-endpoint
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 9 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
a23bfe0
Add token introspection endpoint and related tests
mikaelweave fcf508b
Refactor token introspection endpoint handling and add audit event type
mikaelweave 6410e8f
Implement token introspection service and update controller to use it
mikaelweave e005e0a
Refactor DefaultTokenIntrospectionService to enhance extensibility an…
mikaelweave afb3e4c
Refactor scope claim retrieval for improved readability and efficiency
mikaelweave b7b2d53
Refactor TokenIntrospectionControllerTests to improve resource manage…
mikaelweave a9d08c7
Add TokenIntrospectionTests for RFC 7662 compliance and standard clai…
mikaelweave fc13cc7
Merge branch 'main' into personal/mikaelw/smart-token-introspection-e…
mikaelweave 1e03cac
Add Token Introspection Tests for RFC 7662 compliance and remove lega…
mikaelweave 2d77cc8
Refactor token introspection service and tests
mikaelweave f977da1
Remove test for unsupported content type in introspection
mikaelweave 0b6cd2b
Refactor validation logic and update introspection auth
mikaelweave 5b23a53
Add smart user client credentials to E2E test variables and refactor …
mikaelweave 6b7008b
Removed E2E tests - will code these downstream
mikaelweave File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
305 changes: 305 additions & 0 deletions
305
src/Microsoft.Health.Fhir.Api/Features/Security/DefaultTokenIntrospectionService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,305 @@ | ||
| // ------------------------------------------------------------------------------------------------- | ||
| // 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 extend this class and override ValidateToken() to support 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the security configuration for this service. | ||
| /// </summary> | ||
| protected SecurityConfiguration SecurityConfiguration => _securityConfiguration; | ||
|
|
||
| /// <summary> | ||
| /// Gets the JWT token handler for parsing and validating tokens. | ||
| /// </summary> | ||
| protected JwtSecurityTokenHandler TokenHandler => _tokenHandler; | ||
|
|
||
| /// <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.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(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Validates a JWT token using configured validation parameters. | ||
| /// </summary> | ||
| protected virtual 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"); | ||
| } | ||
mikaelweave marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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.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"); | ||
| } | ||
mikaelweave marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builds TokenValidationParameters from SecurityConfiguration. | ||
| /// </summary> | ||
| protected virtual 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> | ||
| protected 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> | ||
| protected 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> | ||
| protected string GetScopeFromMultipleClaims(ClaimsPrincipal principal) | ||
| { | ||
| // Check all configured scope claim names | ||
| var scopeClaimNames = SecurityConfiguration.Authorization.ScopesClaim ?? new List<string> { "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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Result of token validation. | ||
| /// </summary> | ||
| 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, | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| } | ||
26 changes: 26 additions & 0 deletions
26
src/Microsoft.Health.Fhir.Api/Features/Security/ITokenIntrospectionService.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.