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