diff --git a/src/IdentityServer/Validation/Default/IntrospectionRequestValidator.cs b/src/IdentityServer/Validation/Default/IntrospectionRequestValidator.cs index 4ad4f9c5b..40d1b3b7e 100644 --- a/src/IdentityServer/Validation/Default/IntrospectionRequestValidator.cs +++ b/src/IdentityServer/Validation/Default/IntrospectionRequestValidator.cs @@ -3,6 +3,7 @@ using Duende.IdentityServer.Extensions; +using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using IdentityModel; using Microsoft.Extensions.Logging; @@ -79,19 +80,20 @@ public async Task ValidateAsync(Introspect { if (Constants.SupportedTokenTypeHints.Contains(hint)) { - _logger.LogDebug("Token type hint found in request: {tokenTypeHint}", hint); + if(_logger.IsEnabled(LogLevel.Debug)) + { + var sanitized = hint.Replace(Environment.NewLine, "").Replace("\n", "").Replace("\r", ""); + _logger.LogDebug("Token type hint found in request: {tokenTypeHint}", sanitized); + } } else { - _logger.LogError("Invalid token type hint: {tokenTypeHint}", hint); - return new IntrospectionRequestValidationResult + if (_logger.IsEnabled(LogLevel.Debug)) { - IsError = true, - Api = api, - Client = client, - Error = "invalid_request", - Parameters = parameters - }; + var sanitized = hint.Replace(Environment.NewLine, "").Replace("\n", "").Replace("\r", ""); + _logger.LogDebug("Unsupported token type hint found in request: {tokenTypeHint}", sanitized); + } + hint = null; // Discard an unknown hint, in line with RFC 7662 } } @@ -100,65 +102,50 @@ public async Task ValidateAsync(Introspect if (api != null) { - // if we have an API calling, then the token should only ever be an access token - if (hint.IsMissing() || hint == TokenTypeHints.AccessToken) - { - // validate token - var tokenValidationResult = await _tokenValidator.ValidateAccessTokenAsync(token); - - // success - if (!tokenValidationResult.IsError) - { - _logger.LogDebug("Validated access token"); - claims = tokenValidationResult.Claims; - } - } + // APIs can only introspect access tokens. We ignore the hint and just immediately try to + // validate the token as an access token. If that fails, claims will be null and + // we'll return { "isActive": false }. + claims = await GetAccessTokenClaimsAsync(token); } else { - // clients can pass either token type + // Clients can introspect access tokens and refresh tokens. They can pass a hint to us to + // help us introspect, but RFC 7662 says if the hint is wrong we have to fall back to + // trying the other type. + // + // RFC 7662 (OAuth 2.0 Token Introspection), Section 2.1: + // + // > If the server is unable to locate the token using the given hint, + // > it MUST extend its search across all of its supported token types. + // > An authorization server MAY ignore this parameter, particularly if + // > it is able to detect the token type automatically. + if (hint.IsMissing() || hint == TokenTypeHints.AccessToken) { // try access token - var tokenValidationResult = await _tokenValidator.ValidateAccessTokenAsync(token); - if (!tokenValidationResult.IsError) + claims = await GetAccessTokenClaimsAsync(token, client); + if (claims == null) { - var list = tokenValidationResult.Claims.ToList(); - - var tokenClientId = list.SingleOrDefault(x => x.Type == JwtClaimTypes.ClientId)?.Value; - if (tokenClientId == client.ClientId) + // fall back to refresh token + if (hint.IsPresent()) { - _logger.LogDebug("Validated access token"); - list.Add(new Claim("token_type", TokenTypeHints.AccessToken)); - claims = list; + _logger.LogDebug("Failed to validate token as access token. Possible incorrect token_type_hint parameter."); } + claims = await GetRefreshTokenClaimsAsync(token, client); } } - - if (claims == null) + else { - // we get in here if hint is for refresh token, or the access token lookup failed - var refreshValidationResult = await _refreshTokenService.ValidateRefreshTokenAsync(token, client); - if (!refreshValidationResult.IsError) + // try refresh token + claims = await GetRefreshTokenClaimsAsync(token, client); + if (claims == null) { - _logger.LogDebug("Validated refresh token"); - - var iat = refreshValidationResult.RefreshToken.CreationTime.ToEpochTime(); - var list = new List + // fall back to access token + if (hint.IsPresent()) { - new Claim("client_id", client.ClientId), - new Claim("token_type", TokenTypeHints.RefreshToken), - new Claim("iat", iat.ToString(), ClaimValueTypes.Integer), - new Claim("exp", (iat + refreshValidationResult.RefreshToken.Lifetime).ToString(), ClaimValueTypes.Integer), - new Claim("sub", refreshValidationResult.RefreshToken.SubjectId), - }; - - foreach (var scope in refreshValidationResult.RefreshToken.AuthorizedScopes) - { - list.Add(new Claim("scope", scope)); + _logger.LogDebug("Failed to validate token as refresh token. Possible incorrect token_type_hint parameter."); } - - claims = list; + claims = await GetAccessTokenClaimsAsync(token, client); } } } @@ -193,4 +180,72 @@ public async Task ValidateAsync(Introspect Parameters = parameters }; } + + /// + /// Attempt to obtain the claims for a token as a refresh token for a client. + /// + private async Task> GetRefreshTokenClaimsAsync(string token, Client client) + { + var refreshValidationResult = await _refreshTokenService.ValidateRefreshTokenAsync(token, client); + if (!refreshValidationResult.IsError) + { + var iat = refreshValidationResult.RefreshToken.CreationTime.ToEpochTime(); + var claims = new List + { + new Claim("client_id", client.ClientId), + new Claim("token_type", TokenTypeHints.RefreshToken), + new Claim("iat", iat.ToString(), ClaimValueTypes.Integer), + new Claim("exp", (iat + refreshValidationResult.RefreshToken.Lifetime).ToString(), ClaimValueTypes.Integer), + new Claim("sub", refreshValidationResult.RefreshToken.SubjectId), + }; + + foreach (var scope in refreshValidationResult.RefreshToken.AuthorizedScopes) + { + claims.Add(new Claim("scope", scope)); + } + + return claims; + } + + return null; + } + + /// + /// Attempt to obtain the claims for a token as an access token, and validate that it belongs to the client. + /// + private async Task> GetAccessTokenClaimsAsync(string token, Client client) + { + var tokenValidationResult = await _tokenValidator.ValidateAccessTokenAsync(token); + if (!tokenValidationResult.IsError) + { + var claims = tokenValidationResult.Claims.ToList(); + + var tokenClientId = claims.SingleOrDefault(x => x.Type == JwtClaimTypes.ClientId)?.Value; + if (tokenClientId == client.ClientId) + { + _logger.LogDebug("Validated access token"); + claims.Add(new Claim("token_type", TokenTypeHints.AccessToken)); + return claims; + } + } + + return null; + } + + /// + /// Attempt to obtain the claims for a token as an access token. This overload does no validation that the + /// token belongs to a particular client, and is intended for use when we have an API caller (any API can + /// introspect a token). + /// + private async Task> GetAccessTokenClaimsAsync(string token) + { + var tokenValidationResult = await _tokenValidator.ValidateAccessTokenAsync(token); + if (!tokenValidationResult.IsError) + { + _logger.LogDebug("Validated access token"); + return tokenValidationResult.Claims; + } + + return null; + } } \ No newline at end of file diff --git a/test/IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionTests.cs b/test/IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionTests.cs index 643cdbe39..1f55731ad 100644 --- a/test/IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionTests.cs +++ b/test/IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionTests.cs @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -9,6 +10,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Duende.IdentityServer; using FluentAssertions; using IdentityModel.Client; using IntegrationTests.Endpoints.Introspection.Setup; @@ -129,7 +131,7 @@ public async Task Invalid_Content_type_should_fail() [Fact] [Trait("Category", Category)] - public async Task Invalid_token_type_hint_should_fail() + public async Task Invalid_token_type_hint_should_not_fail() { var tokenResponse = await _client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { @@ -149,7 +151,112 @@ public async Task Invalid_token_type_hint_should_fail() TokenTypeHint = "invalid" }); - introspectionResponse.IsError.Should().BeTrue(); + introspectionResponse.IsActive.Should().Be(true); + introspectionResponse.IsError.Should().Be(false); + + var scopes = from c in introspectionResponse.Claims + where c.Type == "scope" + select c; + + scopes.Count().Should().Be(1); + scopes.First().Value.Should().Be("api1"); + } + + [Theory] + [Trait("Category", Category)] + [InlineData("ro.client", Constants.TokenTypeHints.RefreshToken)] + [InlineData("ro.client", Constants.TokenTypeHints.AccessToken)] + [InlineData("ro.client", "bogus")] + [InlineData("api1", Constants.TokenTypeHints.RefreshToken)] + [InlineData("api1", Constants.TokenTypeHints.AccessToken)] + [InlineData("api1", "bogus")] + public async Task Access_tokens_can_be_introspected_with_any_hint(string introspectedBy, string hint) + { + TokenResponse tokenResponse; + + tokenResponse = await _client.RequestPasswordTokenAsync(new PasswordTokenRequest + { + Address = TokenEndpoint, + ClientId = "ro.client", + ClientSecret = "secret", + UserName = "bob", + Password = "bob", + Scope = "api1 offline_access" + }); + + var introspectionResponse = await _client.IntrospectTokenAsync(new TokenIntrospectionRequest + { + Address = IntrospectionEndpoint, + ClientId = introspectedBy, + ClientSecret = "secret", + + Token = tokenResponse.AccessToken, + TokenTypeHint = hint + }); + + introspectionResponse.IsActive.Should().Be(true); + introspectionResponse.IsError.Should().Be(false); + + var scopes = from c in introspectionResponse.Claims + where c.Type == "scope" + select c.Value; + scopes.Should().Contain("api1"); + } + + [Theory] + [Trait("Category", Category)] + + // Validate that refresh tokens can be introspected with any hint by the client they were issued to + [InlineData("ro.client", Constants.TokenTypeHints.RefreshToken, true)] + [InlineData("ro.client", Constants.TokenTypeHints.AccessToken, true)] + [InlineData("ro.client", "bogus", true)] + + // Validate that APIs cannot introspect refresh tokens and that we always return isActive: false + [InlineData("api1", Constants.TokenTypeHints.RefreshToken, false)] + [InlineData("api1", Constants.TokenTypeHints.AccessToken, false)] + [InlineData("api1", "bogus", false)] + + public async Task Refresh_tokens_can_be_introspected_by_their_client_with_any_hint(string introspectedBy, string hint, bool isActive) + { + TokenResponse tokenResponse; + + tokenResponse = await _client.RequestPasswordTokenAsync(new PasswordTokenRequest + { + Address = TokenEndpoint, + ClientId = "ro.client", + ClientSecret = "secret", + UserName = "bob", + Password = "bob", + Scope = "api1 offline_access" + }); + + var introspectionResponse = await _client.IntrospectTokenAsync(new TokenIntrospectionRequest + { + Address = IntrospectionEndpoint, + ClientId = introspectedBy, + ClientSecret = "secret", + + Token = tokenResponse.RefreshToken, + TokenTypeHint = hint + }); + + if (isActive) + { + introspectionResponse.IsActive.Should().Be(true); + introspectionResponse.IsError.Should().Be(false); + + var scopes = from c in introspectionResponse.Claims + where c.Type == "scope" + select c; + + scopes.Count().Should().Be(2); + scopes.First().Value.Should().Be("api1"); + } + else + { + introspectionResponse.IsActive.Should().Be(false); + introspectionResponse.IsError.Should().Be(false); + } } [Fact]