From d0e4b19b64f26ad1525e9e57ad8af067e00210d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Wed, 5 Feb 2025 20:47:05 +0100 Subject: [PATCH 1/4] Implement all properties for TokenIntrospectionResponse optional claims As described by [RFC 7662 - OAuth 2.0 Token Introspection][1] [1]: https://datatracker.ietf.org/doc/html/rfc7662 --- .../Messages/TokenIntrospectionResponse.cs | 130 +++++++++++++++++- .../TokenIntrospectionTests.cs | 55 +++++++- ...ficationTests.VerifyPublicApi.verified.txt | 11 ++ 3 files changed, 194 insertions(+), 2 deletions(-) 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..24d358ff 100644 --- a/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs +++ b/identity-model/test/IdentityModel.Tests/HttpClientExtensions/TokenIntrospectionTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Duende Software. All rights reserved. +// 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.Net.Http.Headers; @@ -14,6 +14,9 @@ public class TokenIntrospectionTests { private const string Endpoint = "http://server/token"; + private static readonly DateTimeOffset NotBeforeDate = new(year: 2016, month: 10, day: 7, hour: 7, minute:21, second: 11, offset: TimeSpan.Zero); + private static readonly DateTimeOffset ExpirationDate = new(year: 2016, month: 10, day: 7, hour: 8, minute:21, second: 11, offset: TimeSpan.Zero); + [Fact] public async Task Http_request_should_have_correct_format() { @@ -81,6 +84,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(NotBeforeDate); + response.Expiration.ShouldBe(ExpirationDate); + response.Subject.ShouldBe("1"); + response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]); + response.Issuer.ShouldBe("https://idsvr4"); + response.JwtId.ShouldBeNull(); } [Fact] @@ -119,6 +132,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(NotBeforeDate); + response.Expiration.ShouldBe(ExpirationDate); + response.Subject.ShouldBe("1"); + response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]); + response.Issuer.ShouldBeNull(); + response.JwtId.ShouldBeNull(); } [Fact] @@ -160,6 +183,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(NotBeforeDate); + response.Expiration.ShouldBe(ExpirationDate); + 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 +202,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(NotBeforeDate); + response.Expiration.ShouldBe(ExpirationDate); + response.Subject.ShouldBe("1"); + response.Audiences.ShouldBe(["https://idsvr4/resources", "api1"]); + response.Issuer.ShouldBe("https://idsvr4"); + response.JwtId.ShouldBeNull(); } [Fact] @@ -277,6 +320,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(NotBeforeDate); + response.Expiration.ShouldBe(ExpirationDate); + 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 From 2158f75a0d42daaa691d27d93cfa8bb61ae3253e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Tue, 21 Jan 2025 17:47:10 +0100 Subject: [PATCH 2/4] Don't use `Lazy` for TokenIntrospectionResponse properties --- .../Messages/TokenIntrospectionResponse.cs | 82 ++++++++----------- 1 file changed, 32 insertions(+), 50 deletions(-) diff --git a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs index 6a8e23d6..d58f62b8 100644 --- a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs +++ b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs @@ -13,45 +13,6 @@ namespace Duende.IdentityModel.Client; /// 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. /// @@ -95,12 +56,33 @@ protected override Task InitializeAsync(object? initializationData = null) } // } + Scopes = claims.Where(c => c.Type == JwtClaimTypes.Scope).Select(c => c.Value).ToArray(); + ClientId = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.ClientId)?.Value; + UserName = claims.FirstOrDefault(c => c.Type == "username")?.Value; + TokenType = claims.FirstOrDefault(c => c.Type == "token_type")?.Value; + Expiration = GetTime(claims, JwtClaimTypes.Expiration); + IssuedAt = GetTime(claims, JwtClaimTypes.IssuedAt); + NotBefore = GetTime(claims, JwtClaimTypes.NotBefore); + Subject = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value; + Audiences = claims.Where(c => c.Type == JwtClaimTypes.Audience).Select(c => c.Value).ToArray(); + Issuer = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Issuer)?.Value; + JwtId = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.JwtId)?.Value; + Claims = claims; } return Task.CompletedTask; } + private static DateTimeOffset? GetTime(List claims, 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); + } + /// /// Gets a value indicating whether the token is active. /// @@ -115,7 +97,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The list of scopes associated to the token or an empty array if no scope claim is present. /// - public string[] Scopes => _scopes.Value; + public string[] Scopes { get; private set; } = []; /// /// Gets the client identifier for the OAuth 2.0 client that requested the token. @@ -123,7 +105,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// 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; + public string? ClientId { get; private set; } /// /// Gets the human-readable identifier for the resource owner who authorized the token. @@ -131,7 +113,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// 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; + public string? UserName { get; private set; } /// /// Gets the type of the token as defined in section 5.1 of OAuth 2.0 (RFC6749). @@ -139,7 +121,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// 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; + public string? TokenType { get; private set; } /// /// Gets the time on or after which the token must not be accepted for processing. @@ -147,7 +129,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The expiration time of the token or null if the exp claim is missing. /// - public DateTimeOffset? Expiration => _expiration.Value; + public DateTimeOffset? Expiration { get; private set; } /// /// Gets the time when the token was issued. @@ -155,7 +137,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The issuance time of the token or null if the iat claim is missing. /// - public DateTimeOffset? IssuedAt => _issuedAt.Value; + public DateTimeOffset? IssuedAt { get; private set; } /// /// Gets the time before which the token must not be accepted for processing. @@ -163,7 +145,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The validity start time of the token or null if the nbf claim is missing. /// - public DateTimeOffset? NotBefore => _notBefore.Value; + public DateTimeOffset? NotBefore { get; private set; } /// /// Gets the subject of the token. Usually a machine-readable identifier of the resource owner who authorized the token. @@ -171,7 +153,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The subject of the token or null if the sub claim is missing. /// - public string? Subject => _subject.Value; + public string? Subject { get; private set; } /// /// Gets the service-specific list of string identifiers representing the intended audience for the token. @@ -179,7 +161,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// 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; + public string[] Audiences { get; private set; } = []; /// /// Gets the string representing the issuer of the token. @@ -187,7 +169,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The string representing the issuer of the token or null if the iss claim is missing. /// - public string? Issuer => _issuer.Value; + public string? Issuer { get; private set; } /// /// Gets the string identifier for the token. @@ -195,7 +177,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// /// The string identifier for the token or null if the jti claim is missing. /// - public string? JwtId => _jwtId.Value; + public string? JwtId { get; private set; } /// /// Gets the claims. From f262a4451db8c2d3cd155da38b2ef7cb655c048d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Luthi?= Date: Wed, 5 Feb 2025 20:56:29 +0100 Subject: [PATCH 3/4] Don't throw if the exp, iat or nbf claim is not a valid number --- .../Client/Messages/TokenIntrospectionResponse.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs index d58f62b8..8417e03f 100644 --- a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs +++ b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs @@ -78,8 +78,8 @@ protected override Task InitializeAsync(object? initializationData = null) { var claimValue = claims.FirstOrDefault(e => e.Type == claimType)?.Value; if (claimValue == null) return null; + if (!long.TryParse(claimValue, NumberStyles.AllowLeadingSign, NumberFormatInfo.InvariantInfo, out var seconds)) return null; - var seconds = long.Parse(claimValue, NumberStyles.AllowLeadingSign, NumberFormatInfo.InvariantInfo); return DateTimeOffset.FromUnixTimeSeconds(seconds); } @@ -127,7 +127,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// 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. + /// The expiration time of the token or null if the exp claim is either missing or not a valid number. /// public DateTimeOffset? Expiration { get; private set; } @@ -135,7 +135,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// Gets the time when the token was issued. /// /// - /// The issuance time of the token or null if the iat claim is missing. + /// The issuance time of the token or null if the iat claim is either missing or not a valid number. /// public DateTimeOffset? IssuedAt { get; private set; } @@ -143,7 +143,7 @@ protected override Task InitializeAsync(object? initializationData = null) /// 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. + /// The validity start time of the token or null if the nbf claim is either missing or not a valid number. /// public DateTimeOffset? NotBefore { get; private set; } From 930236400eaad5b77a5e5d55b8704b6527d3a1b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ce=CC=81dric=20Luthi?= Date: Sun, 23 Feb 2025 23:41:50 +0100 Subject: [PATCH 4/4] Do not hide potential claim to DateTimeOffset conversion exception --- .../Messages/TokenIntrospectionResponse.cs | 67 +++++++++++++++---- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs index 8417e03f..883a2e81 100644 --- a/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs +++ b/identity-model/src/IdentityModel/Client/Messages/TokenIntrospectionResponse.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Globalization; +using System.Runtime.ExceptionServices; using System.Security.Claims; using System.Text.Json; @@ -13,6 +14,14 @@ namespace Duende.IdentityModel.Client; /// public class TokenIntrospectionResponse : ProtocolResponse { + private DateTimeOffset? _expiration; + private DateTimeOffset? _issuedAt; + private DateTimeOffset? _notBefore; + + private ExceptionDispatchInfo? _expirationException; + private ExceptionDispatchInfo? _issuedAtException; + private ExceptionDispatchInfo? _notBeforeException; + /// /// Allows to initialize instance specific data. /// @@ -60,9 +69,9 @@ protected override Task InitializeAsync(object? initializationData = null) ClientId = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.ClientId)?.Value; UserName = claims.FirstOrDefault(c => c.Type == "username")?.Value; TokenType = claims.FirstOrDefault(c => c.Type == "token_type")?.Value; - Expiration = GetTime(claims, JwtClaimTypes.Expiration); - IssuedAt = GetTime(claims, JwtClaimTypes.IssuedAt); - NotBefore = GetTime(claims, JwtClaimTypes.NotBefore); + _expiration = GetDateTimeOffset(claims, JwtClaimTypes.Expiration, ref _expirationException); + _issuedAt = GetDateTimeOffset(claims, JwtClaimTypes.IssuedAt, ref _issuedAtException); + _notBefore = GetDateTimeOffset(claims, JwtClaimTypes.NotBefore, ref _notBeforeException); Subject = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value; Audiences = claims.Where(c => c.Type == JwtClaimTypes.Audience).Select(c => c.Value).ToArray(); Issuer = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Issuer)?.Value; @@ -74,13 +83,24 @@ protected override Task InitializeAsync(object? initializationData = null) return Task.CompletedTask; } - private static DateTimeOffset? GetTime(List claims, string claimType) + private static DateTimeOffset? GetDateTimeOffset(List claims, string claimType, ref ExceptionDispatchInfo? exceptionDispatchInfo) { var claimValue = claims.FirstOrDefault(e => e.Type == claimType)?.Value; - if (claimValue == null) return null; - if (!long.TryParse(claimValue, NumberStyles.AllowLeadingSign, NumberFormatInfo.InvariantInfo, out var seconds)) return null; + if (claimValue == null) + { + return null; + } - return DateTimeOffset.FromUnixTimeSeconds(seconds); + try + { + var seconds = long.Parse(claimValue, NumberStyles.AllowLeadingSign, NumberFormatInfo.InvariantInfo); + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + catch (Exception exception) + { + exceptionDispatchInfo = ExceptionDispatchInfo.Capture(exception); + return null; + } } /// @@ -127,25 +147,46 @@ protected override Task InitializeAsync(object? initializationData = null) /// 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 either missing or not a valid number. + /// The expiration time of the token or null if the exp claim is missing. /// - public DateTimeOffset? Expiration { get; private set; } + public DateTimeOffset? Expiration + { + get + { + _expirationException?.Throw(); + return _expiration; + } + } /// /// Gets the time when the token was issued. /// /// - /// The issuance time of the token or null if the iat claim is either missing or not a valid number. + /// The issuance time of the token or null if the iat claim is missing. /// - public DateTimeOffset? IssuedAt { get; private set; } + public DateTimeOffset? IssuedAt + { + get + { + _issuedAtException?.Throw(); + return _issuedAt; + } + } /// /// 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 either missing or not a valid number. + /// The validity start time of the token or null if the nbf claim is missing. /// - public DateTimeOffset? NotBefore { get; private set; } + public DateTimeOffset? NotBefore + { + get + { + _notBeforeException?.Throw(); + return _notBefore; + } + } /// /// Gets the subject of the token. Usually a machine-readable identifier of the resource owner who authorized the token.