Skip to content
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

Fall back to other token types when given incorrect hint during introspection #1607

Merged
merged 3 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
161 changes: 108 additions & 53 deletions src/IdentityServer/Validation/Default/IntrospectionRequestValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@


using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using IdentityModel;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -79,19 +80,20 @@ public async Task<IntrospectionRequestValidationResult> 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
}
}

Expand All @@ -100,65 +102,50 @@ public async Task<IntrospectionRequestValidationResult> 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<Claim>
// 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);
}
}
}
Expand Down Expand Up @@ -193,4 +180,72 @@ public async Task<IntrospectionRequestValidationResult> ValidateAsync(Introspect
Parameters = parameters
};
}

/// <summary>
/// Attempt to obtain the claims for a token as a refresh token for a client.
/// </summary>
private async Task<IEnumerable<Claim>> 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<Claim>
{
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;
}

/// <summary>
/// Attempt to obtain the claims for a token as an access token, and validate that it belongs to the client.
/// </summary>
private async Task<IEnumerable<Claim>> 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;
}

/// <summary>
/// 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).
/// </summary>
private async Task<IEnumerable<Claim>> GetAccessTokenClaimsAsync(string token)
{
var tokenValidationResult = await _tokenValidator.ValidateAccessTokenAsync(token);
if (!tokenValidationResult.IsError)
{
_logger.LogDebug("Validated access token");
return tokenValidationResult.Claims;
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
// See LICENSE in the project root for license information.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Duende.IdentityServer;
using FluentAssertions;
using IdentityModel.Client;
using IntegrationTests.Endpoints.Introspection.Setup;
Expand Down Expand Up @@ -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
{
Expand All @@ -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]
Expand Down
Loading