diff --git a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs
index 3b2ee1a4..6a8e23d6 100644
--- a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs
+++ b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs
@@ -1,17 +1,57 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
+using System.Globalization;
using System.Security.Claims;
using System.Text.Json;
namespace Duende.IdentityModel.Client;
///
-/// Models an OAuth 2.0 introspection response
+/// Models an OAuth 2.0 introspection response as defined by RFC 7662 - OAuth 2.0 Token Introspection
///
///
public class TokenIntrospectionResponse : ProtocolResponse
{
+ private readonly Lazy _scopes;
+ private readonly Lazy _clientId;
+ private readonly Lazy _userName;
+ private readonly Lazy _tokenType;
+ private readonly Lazy _expiration;
+ private readonly Lazy _issuedAt;
+ private readonly Lazy _notBefore;
+ private readonly Lazy _subject;
+ private readonly Lazy _audiences;
+ private readonly Lazy _issuer;
+ private readonly Lazy _jwtId;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TokenIntrospectionResponse()
+ {
+ _scopes = new Lazy(() => Claims.Where(c => c.Type == JwtClaimTypes.Scope).Select(c => c.Value).ToArray());
+ _clientId = new Lazy(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.ClientId)?.Value);
+ _userName = new Lazy(() => Claims.FirstOrDefault(c => c.Type == "username")?.Value);
+ _tokenType = new Lazy(() => Claims.FirstOrDefault(c => c.Type == "token_type")?.Value);
+ _expiration = new Lazy(() => GetTime(JwtClaimTypes.Expiration));
+ _issuedAt = new Lazy(() => GetTime(JwtClaimTypes.IssuedAt));
+ _notBefore = new Lazy(() => GetTime(JwtClaimTypes.NotBefore));
+ _subject = new Lazy(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value);
+ _audiences = new Lazy(() => Claims.Where(c => c.Type == JwtClaimTypes.Audience).Select(c => c.Value).ToArray());
+ _issuer = new Lazy(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Issuer)?.Value);
+ _jwtId = new Lazy(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.JwtId)?.Value);
+ }
+
+ private DateTimeOffset? GetTime(string claimType)
+ {
+ var claimValue = Claims.FirstOrDefault(e => e.Type == claimType)?.Value;
+ if (claimValue == null) return null;
+
+ var seconds = long.Parse(claimValue, NumberStyles.AllowLeadingSign, NumberFormatInfo.InvariantInfo);
+ return DateTimeOffset.FromUnixTimeSeconds(seconds);
+ }
+
///
/// Allows to initialize instance specific data.
///
@@ -69,6 +109,94 @@ protected override Task InitializeAsync(object? initializationData = null)
///
public bool IsActive => Json?.TryGetBoolean("active") ?? false;
+ ///
+ /// Gets the list of scopes associated to the token.
+ ///
+ ///
+ /// The list of scopes associated to the token or an empty array if no scope claim is present.
+ ///
+ public string[] Scopes => _scopes.Value;
+
+ ///
+ /// Gets the client identifier for the OAuth 2.0 client that requested the token.
+ ///
+ ///
+ /// The client identifier for the OAuth 2.0 client that requested the token or null if the client_id claim is missing.
+ ///
+ public string? ClientId => _clientId.Value;
+
+ ///
+ /// Gets the human-readable identifier for the resource owner who authorized the token.
+ ///
+ ///
+ /// The human-readable identifier for the resource owner who authorized the token or null if the username claim is missing.
+ ///
+ public string? UserName => _userName.Value;
+
+ ///
+ /// Gets the type of the token as defined in section 5.1 of OAuth 2.0 (RFC6749).
+ ///
+ ///
+ /// The type of the token as defined in section 5.1 of OAuth 2.0 (RFC6749) or null if the token_type claim is missing.
+ ///
+ public string? TokenType => _tokenType.Value;
+
+ ///
+ /// Gets the time on or after which the token must not be accepted for processing.
+ ///
+ ///
+ /// The expiration time of the token or null if the exp claim is missing.
+ ///
+ public DateTimeOffset? Expiration => _expiration.Value;
+
+ ///
+ /// Gets the time when the token was issued.
+ ///
+ ///
+ /// The issuance time of the token or null if the iat claim is missing.
+ ///
+ public DateTimeOffset? IssuedAt => _issuedAt.Value;
+
+ ///
+ /// Gets the time before which the token must not be accepted for processing.
+ ///
+ ///
+ /// The validity start time of the token or null if the nbf claim is missing.
+ ///
+ public DateTimeOffset? NotBefore => _notBefore.Value;
+
+ ///
+ /// Gets the subject of the token. Usually a machine-readable identifier of the resource owner who authorized the token.
+ ///
+ ///
+ /// The subject of the token or null if the sub claim is missing.
+ ///
+ public string? Subject => _subject.Value;
+
+ ///
+ /// Gets the service-specific list of string identifiers representing the intended audience for the token.
+ ///
+ ///
+ /// The service-specific list of string identifiers representing the intended audience for the token or an empty array if no aud claim is present.
+ ///
+ public string[] Audiences => _audiences.Value;
+
+ ///
+ /// Gets the string representing the issuer of the token.
+ ///
+ ///
+ /// The string representing the issuer of the token or null if the iss claim is missing.
+ ///
+ public string? Issuer => _issuer.Value;
+
+ ///
+ /// Gets the string identifier for the token.
+ ///
+ ///
+ /// The string identifier for the token or null if the jti claim is missing.
+ ///
+ public string? JwtId => _jwtId.Value;
+
///
/// Gets the claims.
///
diff --git a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs
index 6c3ff83e..31532f96 100644
--- a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs
+++ b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs
@@ -2,12 +2,12 @@
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
using System.Net;
-using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using Duende.IdentityModel.Client;
using Duende.IdentityModel.Infrastructure;
using FluentAssertions;
+using FluentAssertions.Extensions;
namespace Duende.IdentityModel.HttpClientExtensions
{
@@ -84,6 +84,16 @@ public async Task Success_protocol_response_should_be_handled_correctly()
new Claim("scope", "api1", ClaimValueTypes.String, "https://idsvr4"),
new Claim("scope", "api2", ClaimValueTypes.String, "https://idsvr4"),
});
+ response.Scopes.Should().BeEquivalentTo("api1", "api2");
+ response.ClientId.Should().Be("client");
+ response.UserName.Should().BeNull();
+ response.IssuedAt.Should().BeNull();
+ response.NotBefore.Should().Be(7.October(2016).At(7, 21, 11).WithOffset(0.Hours()));
+ response.Expiration.Should().Be(7.October(2016).At(8, 21, 11).WithOffset(0.Hours()));
+ response.Subject.Should().Be("1");
+ response.Audiences.Should().BeEquivalentTo("https://idsvr4/resources", "api1");
+ response.Issuer.Should().Be("https://idsvr4");
+ response.JwtId.Should().BeNull();
}
[Fact]
@@ -121,6 +131,16 @@ public async Task Success_protocol_response_without_issuer_should_be_handled_cor
new Claim("scope", "api1", ClaimValueTypes.String, "LOCAL AUTHORITY"),
new Claim("scope", "api2", ClaimValueTypes.String, "LOCAL AUTHORITY"),
});
+ response.Scopes.Should().BeEquivalentTo("api1", "api2");
+ response.ClientId.Should().Be("client");
+ response.UserName.Should().BeNull();
+ response.IssuedAt.Should().BeNull();
+ response.NotBefore.Should().Be(7.October(2016).At(7, 21, 11).WithOffset(0.Hours()));
+ response.Expiration.Should().Be(7.October(2016).At(8, 21, 11).WithOffset(0.Hours()));
+ response.Subject.Should().Be("1");
+ response.Audiences.Should().BeEquivalentTo("https://idsvr4/resources", "api1");
+ response.Issuer.Should().BeNull();
+ response.JwtId.Should().BeNull();
}
[Fact]
@@ -161,6 +181,16 @@ public async Task Repeating_a_request_should_succeed()
new Claim("scope", "api1", ClaimValueTypes.String, "https://idsvr4"),
new Claim("scope", "api2", ClaimValueTypes.String, "https://idsvr4"),
});
+ response.Scopes.Should().BeEquivalentTo("api1", "api2");
+ response.ClientId.Should().Be("client");
+ response.UserName.Should().BeNull();
+ response.IssuedAt.Should().BeNull();
+ response.NotBefore.Should().Be(7.October(2016).At(7, 21, 11).WithOffset(0.Hours()));
+ response.Expiration.Should().Be(7.October(2016).At(8, 21, 11).WithOffset(0.Hours()));
+ response.Subject.Should().Be("1");
+ response.Audiences.Should().BeEquivalentTo("https://idsvr4/resources", "api1");
+ response.Issuer.Should().Be("https://idsvr4");
+ response.JwtId.Should().BeNull();
// repeat
response = await client.IntrospectTokenAsync(request);
@@ -185,6 +215,16 @@ public async Task Repeating_a_request_should_succeed()
new Claim("scope", "api1", ClaimValueTypes.String, "https://idsvr4"),
new Claim("scope", "api2", ClaimValueTypes.String, "https://idsvr4"),
});
+ response.Scopes.Should().BeEquivalentTo("api1", "api2");
+ response.ClientId.Should().Be("client");
+ response.UserName.Should().BeNull();
+ response.IssuedAt.Should().BeNull();
+ response.NotBefore.Should().Be(7.October(2016).At(7, 21, 11).WithOffset(0.Hours()));
+ response.Expiration.Should().Be(7.October(2016).At(8, 21, 11).WithOffset(0.Hours()));
+ response.Subject.Should().Be("1");
+ response.Audiences.Should().BeEquivalentTo("https://idsvr4/resources", "api1");
+ response.Issuer.Should().Be("https://idsvr4");
+ response.JwtId.Should().BeNull();
}
[Fact]
@@ -292,6 +332,16 @@ public async Task Legacy_protocol_response_should_be_handled_correctly()
new Claim("scope", "api1", ClaimValueTypes.String, "https://idsvr4"),
new Claim("scope", "api2", ClaimValueTypes.String, "https://idsvr4"),
});
+ response.Scopes.Should().BeEquivalentTo("api1", "api2");
+ response.ClientId.Should().Be("client");
+ response.UserName.Should().BeNull();
+ response.IssuedAt.Should().BeNull();
+ response.NotBefore.Should().Be(7.October(2016).At(7, 21, 11).WithOffset(0.Hours()));
+ response.Expiration.Should().Be(7.October(2016).At(8, 21, 11).WithOffset(0.Hours()));
+ response.Subject.Should().Be("1");
+ response.Audiences.Should().BeEquivalentTo("https://idsvr4/resources", "api1");
+ response.Issuer.Should().Be("https://idsvr4");
+ response.JwtId.Should().BeNull();
}
[Fact]
diff --git a/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt b/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt
index f170060c..6d1552fd 100644
--- a/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt
+++ b/identity-model/test/IdentityModel.Tests/Verifications/PublicApiVerificationTests.VerifyPublicApi.verified.txt
@@ -1248,8 +1248,19 @@ namespace Duende.IdentityModel.Client
public class TokenIntrospectionResponse : Duende.IdentityModel.Client.ProtocolResponse
{
public TokenIntrospectionResponse() { }
+ public string[] Audiences { get; }
public System.Collections.Generic.IEnumerable Claims { get; set; }
+ public string? ClientId { get; }
+ public System.DateTimeOffset? Expiration { get; }
public bool IsActive { get; }
+ public System.DateTimeOffset? IssuedAt { get; }
+ public string? Issuer { get; }
+ public string? JwtId { get; }
+ public System.DateTimeOffset? NotBefore { get; }
+ public string[] Scopes { get; }
+ public string? Subject { get; }
+ public string? TokenType { get; }
+ public string? UserName { get; }
protected override System.Threading.Tasks.Task InitializeAsync(object? initializationData = null) { }
}
public class TokenRequest : Duende.IdentityModel.Client.ProtocolRequest