Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement all properties for TokenIntrospectionResponse optional claims #55

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
// 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.Runtime.ExceptionServices;
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 DateTimeOffset? _expiration;
private DateTimeOffset? _issuedAt;
private DateTimeOffset? _notBefore;

private ExceptionDispatchInfo? _expirationException;
private ExceptionDispatchInfo? _issuedAtException;
private ExceptionDispatchInfo? _notBeforeException;

/// <summary>
/// Allows to initialize instance specific data.
/// </summary>
Expand Down Expand Up @@ -55,12 +65,44 @@ 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 = 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;
JwtId = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.JwtId)?.Value;

Claims = claims;
}

return Task.CompletedTask;
}

private static DateTimeOffset? GetDateTimeOffset(List<Claim> claims, string claimType, ref ExceptionDispatchInfo? exceptionDispatchInfo)
{
var claimValue = claims.FirstOrDefault(e => e.Type == claimType)?.Value;
if (claimValue == null)
{
return null;
}

try
{
var seconds = long.Parse(claimValue, NumberStyles.AllowLeadingSign, NumberFormatInfo.InvariantInfo);
return DateTimeOffset.FromUnixTimeSeconds(seconds);
}
catch (Exception exception)
{
exceptionDispatchInfo = ExceptionDispatchInfo.Capture(exception);
return null;
}
}

/// <summary>
/// Gets a value indicating whether the token is active.
/// </summary>
Expand All @@ -69,6 +111,115 @@ 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 { get; private set; } = [];

/// <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 { get; private set; }

/// <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 { get; private set; }

/// <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 { get; private set; }

/// <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
{
get
{
_expirationException?.Throw();
return _expiration;
}
}

/// <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
{
get
{
_issuedAtException?.Throw();
return _issuedAt;
}
}

/// <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
{
get
{
_notBeforeException?.Throw();
return _notBefore;
}
}

/// <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 { get; private set; }

/// <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 { get; private set; } = [];

/// <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 { get; private set; }

/// <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 { get; private set; }

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