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 5979716f..ee42dcdb 100644
--- a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs
+++ b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs
@@ -6,7 +6,6 @@
using System.Text.Json;
using Duende.IdentityModel.Client;
using Duende.IdentityModel.Infrastructure;
-using Shouldly;
namespace Duende.IdentityModel.HttpClientExtensions
{
@@ -81,6 +80,16 @@ public async Task Success_protocol_response_should_be_handled_correctly()
new("scope", "api2", ClaimValueTypes.String, "https://idsvr4"),
};
response.Claims.ShouldBe(expected, new ClaimComparer());
+ response.Scopes.ShouldBe(["api1", "api2"]);
+ response.ClientId.ShouldBe("client");
+ response.UserName.ShouldBeNull();
+ response.IssuedAt.ShouldBeNull();
+ response.NotBefore.ShouldBe(new DateTimeOffset(2016, 10, 7, 7, 21, 11, TimeSpan.FromHours(0)));
+ response.Expiration.ShouldBe(new DateTimeOffset(2016, 10, 7, 8, 21, 11, TimeSpan.FromHours(0)));
+ response.Subject.ShouldBe("1");
+ response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]);
+ response.Issuer.ShouldBe("https://idsvr4");
+ response.JwtId.ShouldBeNull();
}
[Fact]
@@ -119,6 +128,16 @@ public async Task Success_protocol_response_without_issuer_should_be_handled_cor
new("scope", "api2", ClaimValueTypes.String, "LOCAL AUTHORITY"),
};
response.Claims.ShouldBe(expectedClaims, new ClaimComparer());
+ response.Scopes.ShouldBe(["api1", "api2"]);
+ response.ClientId.ShouldBe("client");
+ response.UserName.ShouldBeNull();
+ response.IssuedAt.ShouldBeNull();
+ response.NotBefore.ShouldBe(new DateTimeOffset(2016, 10, 7, 7, 21, 11, TimeSpan.FromHours(0)));
+ response.Expiration.ShouldBe(new DateTimeOffset(2016, 10, 7, 8, 21, 11, TimeSpan.FromHours(0)));
+ response.Subject.ShouldBe("1");
+ response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]);
+ response.Issuer.ShouldBeNull();
+ response.JwtId.ShouldBeNull();
}
[Fact]
@@ -160,6 +179,16 @@ public async Task Repeating_a_request_should_succeed()
new("scope", "api2", ClaimValueTypes.String, "https://idsvr4"),
};
response.Claims.ShouldBe(expectedClaims, new ClaimComparer());
+ response.Scopes.ShouldBe(["api1", "api2"]);
+ response.ClientId.ShouldBe("client");
+ response.UserName.ShouldBeNull();
+ response.IssuedAt.ShouldBeNull();
+ response.NotBefore.ShouldBe(new DateTimeOffset(2016, 10, 7, 7, 21, 11, TimeSpan.FromHours(0)));
+ response.Expiration.ShouldBe(new DateTimeOffset(2016, 10, 7, 8, 21, 11, TimeSpan.FromHours(0)));
+ response.Subject.ShouldBe("1");
+ response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]);
+ response.Issuer.ShouldBe("https://idsvr4");
+ response.JwtId.ShouldBeNull();
// repeat
response = await client.IntrospectTokenAsync(request);
@@ -169,6 +198,16 @@ public async Task Repeating_a_request_should_succeed()
response.HttpStatusCode.ShouldBe(HttpStatusCode.OK);
response.IsActive.ShouldBeTrue();
response.Claims.ShouldBe(expectedClaims, new ClaimComparer());
+ response.Scopes.ShouldBe(["api1", "api2"]);
+ response.ClientId.ShouldBe("client");
+ response.UserName.ShouldBeNull();
+ response.IssuedAt.ShouldBeNull();
+ response.NotBefore.ShouldBe(new DateTimeOffset(2016, 10, 7, 7, 21, 11, TimeSpan.FromHours(0)));
+ response.Expiration.ShouldBe(new DateTimeOffset(2016, 10, 7, 8, 21, 11, TimeSpan.FromHours(0)));
+ response.Subject.ShouldBe("1");
+ response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]);
+ response.Issuer.ShouldBe("https://idsvr4");
+ response.JwtId.ShouldBeNull();
}
[Fact]
@@ -277,6 +316,16 @@ public async Task Legacy_protocol_response_should_be_handled_correctly()
new("scope", "api2", ClaimValueTypes.String, "https://idsvr4")
};
response.Claims.ShouldBe(expectedClaims, new ClaimComparer());
+ response.Scopes.ShouldBe(["api1", "api2"]);
+ response.ClientId.ShouldBe("client");
+ response.UserName.ShouldBeNull();
+ response.IssuedAt.ShouldBeNull();
+ response.NotBefore.ShouldBe(new DateTimeOffset(2016, 10, 7, 7, 21, 11, TimeSpan.FromHours(0)));
+ response.Expiration.ShouldBe(new DateTimeOffset(2016, 10, 7, 8, 21, 11, TimeSpan.FromHours(0)));
+ response.Subject.ShouldBe("1");
+ response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]);
+ response.Issuer.ShouldBe("https://idsvr4");
+ response.JwtId.ShouldBeNull();
}
[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 27288f03..88bdc8db 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