Skip to content

Commit

Permalink
Implement all properties for TokenIntrospectionResponse optional claims
Browse files Browse the repository at this point in the history
As described by [RFC 7662 - OAuth 2.0 Token Introspection][1]

[1]: https://datatracker.ietf.org/doc/html/rfc7662
  • Loading branch information
0xced committed Nov 19, 2024
1 parent 8abee7d commit 37e0dfa
Show file tree
Hide file tree
Showing 3 changed files with 191 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Models an OAuth 2.0 introspection response
/// Models an OAuth 2.0 introspection response as defined by <a href="https://datatracker.ietf.org/doc/html/rfc7662">RFC 7662 - OAuth 2.0 Token Introspection</a>
/// </summary>
/// <seealso cref="ProtocolResponse" />
public class TokenIntrospectionResponse : ProtocolResponse
{
private readonly Lazy<string[]> _scopes;
private readonly Lazy<string?> _clientId;
private readonly Lazy<string?> _userName;
private readonly Lazy<string?> _tokenType;
private readonly Lazy<DateTimeOffset?> _expiration;
private readonly Lazy<DateTimeOffset?> _issuedAt;
private readonly Lazy<DateTimeOffset?> _notBefore;
private readonly Lazy<string?> _subject;
private readonly Lazy<string[]> _audiences;
private readonly Lazy<string?> _issuer;
private readonly Lazy<string?> _jwtId;

/// <summary>
/// Initializes a new instance of the <see cref="TokenIntrospectionResponse"/> class.
/// </summary>
public TokenIntrospectionResponse()
{
_scopes = new Lazy<string[]>(() => Claims.Where(c => c.Type == JwtClaimTypes.Scope).Select(c => c.Value).ToArray());
_clientId = new Lazy<string?>(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.ClientId)?.Value);
_userName = new Lazy<string?>(() => Claims.FirstOrDefault(c => c.Type == "username")?.Value);
_tokenType = new Lazy<string?>(() => Claims.FirstOrDefault(c => c.Type == "token_type")?.Value);
_expiration = new Lazy<DateTimeOffset?>(() => GetTime(JwtClaimTypes.Expiration));
_issuedAt = new Lazy<DateTimeOffset?>(() => GetTime(JwtClaimTypes.IssuedAt));
_notBefore = new Lazy<DateTimeOffset?>(() => GetTime(JwtClaimTypes.NotBefore));
_subject = new Lazy<string?>(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Subject)?.Value);
_audiences = new Lazy<string[]>(() => Claims.Where(c => c.Type == JwtClaimTypes.Audience).Select(c => c.Value).ToArray());
_issuer = new Lazy<string?>(() => Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Issuer)?.Value);
_jwtId = new Lazy<string?>(() => 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);
}

/// <summary>
/// Allows to initialize instance specific data.
/// </summary>
Expand Down Expand Up @@ -69,6 +109,94 @@ protected override Task InitializeAsync(object? initializationData = null)
/// </value>
public bool IsActive => Json?.TryGetBoolean("active") ?? false;

/// <summary>
/// Gets the list of scopes associated to the token.
/// </summary>
/// <value>
/// The list of scopes associated to the token or an empty array if no <c>scope</c> claim is present.
/// </value>
public string[] Scopes => _scopes.Value;

/// <summary>
/// Gets the client identifier for the OAuth 2.0 client that requested the token.
/// </summary>
/// <value>
/// The client identifier for the OAuth 2.0 client that requested the token or null if the <c>client_id</c> claim is missing.
/// </value>
public string? ClientId => _clientId.Value;

/// <summary>
/// Gets the human-readable identifier for the resource owner who authorized the token.
/// </summary>
/// <value>
/// The human-readable identifier for the resource owner who authorized the token or null if the <c>username</c> claim is missing.
/// </value>
public string? UserName => _userName.Value;

/// <summary>
/// Gets the type of the token as defined in <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-5.1">section 5.1 of OAuth 2.0 (RFC6749)</a>.
/// </summary>
/// <value>
/// The type of the token as defined in <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-5.1">section 5.1 of OAuth 2.0 (RFC6749)</a> or null if the <c>token_type</c> claim is missing.
/// </value>
public string? TokenType => _tokenType.Value;

/// <summary>
/// Gets the time on or after which the token must not be accepted for processing.
/// </summary>
/// <value>
/// The expiration time of the token or null if the <c>exp</c> claim is missing.
/// </value>
public DateTimeOffset? Expiration => _expiration.Value;

/// <summary>
/// Gets the time when the token was issued.
/// </summary>
/// <value>
/// The issuance time of the token or null if the <c>iat</c> claim is missing.
/// </value>
public DateTimeOffset? IssuedAt => _issuedAt.Value;

/// <summary>
/// Gets the time before which the token must not be accepted for processing.
/// </summary>
/// <value>
/// The validity start time of the token or null if the <c>nbf</c> claim is missing.
/// </value>
public DateTimeOffset? NotBefore => _notBefore.Value;

/// <summary>
/// Gets the subject of the token. Usually a machine-readable identifier of the resource owner who authorized the token.
/// </summary>
/// <value>
/// The subject of the token or null if the <c>sub</c> claim is missing.
/// </value>
public string? Subject => _subject.Value;

/// <summary>
/// Gets the service-specific list of string identifiers representing the intended audience for the token.
/// </summary>
/// <value>
/// The service-specific list of string identifiers representing the intended audience for the token or an empty array if no <c>aud</c> claim is present.
/// </value>
public string[] Audiences => _audiences.Value;

/// <summary>
/// Gets the string representing the issuer of the token.
/// </summary>
/// <value>
/// The string representing the issuer of the token or null if the <c>iss</c> claim is missing.
/// </value>
public string? Issuer => _issuer.Value;

/// <summary>
/// Gets the string identifier for the token.
/// </summary>
/// <value>
/// The string identifier for the token or null if the <c>jti</c> claim is missing.
/// </value>
public string? JwtId => _jwtId.Value;

/// <summary>
/// Gets the claims.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<System.Security.Claims.Claim> 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
Expand Down

0 comments on commit 37e0dfa

Please sign in to comment.