Skip to content
This repository was archived by the owner on Dec 24, 2020. It is now read-only.

Commit 16b3995

Browse files
committed
Improve and harden the claims handling logic used by the introspection middleware
1 parent ceee2fd commit 16b3995

24 files changed

Lines changed: 526 additions & 203 deletions

src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionConfiguration.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
using System;
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information
4+
* concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using System;
28
using System.Threading;
39
using System.Threading.Tasks;
410
using JetBrains.Annotations;

src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionConstants.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ public static class Claims
2424
public const string Username = "username";
2525
}
2626

27+
public static class ClaimValueTypes
28+
{
29+
public const string Json = "JSON";
30+
public const string JsonArray = "JSON_ARRAY";
31+
}
32+
2733
public static class Errors
2834
{
2935
public const string InsufficientScope = "insufficient_scope";
@@ -65,6 +71,11 @@ public static class Schemes
6571
public const string Bearer = "Bearer";
6672
}
6773

74+
public static class Separators
75+
{
76+
public static readonly char[] Space = { ' ' };
77+
}
78+
6879
public static class TokenTypes
6980
{
7081
public const string AccessToken = "access_token";

src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionError.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
namespace AspNet.Security.OAuth.Introspection
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information
4+
* concerning the license and the contributors participating to this project.
5+
*/
6+
7+
namespace AspNet.Security.OAuth.Introspection
28
{
39
/// <summary>
410
/// Represents an OAuth2 introspection error.

src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionFeature.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
namespace AspNet.Security.OAuth.Introspection
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Extensions for more information
4+
* concerning the license and the contributors participating to this project.
5+
*/
6+
7+
namespace AspNet.Security.OAuth.Introspection
28
{
39
/// <summary>
410
/// Exposes the OAuth2 introspection details

src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionHandler.cs

Lines changed: 172 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using System;
88
using System.Collections.Generic;
99
using System.Diagnostics;
10+
using System.IO;
11+
using System.Linq;
1012
using System.Net.Http;
1113
using System.Net.Http.Headers;
1214
using System.Security.Claims;
@@ -402,7 +404,34 @@ protected virtual async Task<JObject> GetIntrospectionPayloadAsync(string token)
402404
return null;
403405
}
404406

405-
return JObject.Parse(await response.Content.ReadAsStringAsync());
407+
using (var stream = await response.Content.ReadAsStreamAsync())
408+
using (var reader = new JsonTextReader(new StreamReader(stream)))
409+
{
410+
// Limit the maximum depth to prevent stack overflow exceptions from
411+
// being thrown when receiving deeply nested introspection responses.
412+
reader.MaxDepth = 20;
413+
414+
try
415+
{
416+
var payload = JObject.Load(reader);
417+
418+
Logger.LogInformation("The introspection response was successfully extracted: {Response}.", payload);
419+
420+
return payload;
421+
}
422+
423+
// Swallow the known exceptions thrown by JSON.NET.
424+
catch (Exception exception) when (exception is ArgumentException ||
425+
exception is FormatException ||
426+
exception is InvalidCastException ||
427+
exception is JsonReaderException ||
428+
exception is JsonSerializationException)
429+
{
430+
Logger.LogError("An error occurred while deserializing the introspection response: {Exception}.", exception);
431+
432+
return null;
433+
}
434+
}
406435
}
407436

408437
protected virtual bool ValidateAudience(AuthenticationTicket ticket)
@@ -449,120 +478,215 @@ protected virtual async Task<AuthenticationTicket> CreateTicketAsync(string toke
449478

450479
foreach (var property in payload.Properties())
451480
{
481+
// Always exclude null values, as they can't be represented as valid claims.
482+
if (property.Value.Type == JTokenType.None || property.Value.Type == JTokenType.Null)
483+
{
484+
Logger.LogInformation("The '{Claim}' claim was excluded because it was null.", property.Name);
485+
486+
continue;
487+
}
488+
489+
// When the claim correspond to a protocol claim, store it as an
490+
// authentication property instead of adding it as a proper claim.
452491
switch (property.Name)
453492
{
454-
// Ignore the unwanted claims.
493+
// Always exclude the unwanted protocol claims.
455494
case OAuthIntrospectionConstants.Claims.Active:
456495
case OAuthIntrospectionConstants.Claims.TokenType:
457496
case OAuthIntrospectionConstants.Claims.NotBefore:
458497
continue;
459498

460499
case OAuthIntrospectionConstants.Claims.IssuedAt:
461500
{
462-
#if NETSTANDARD1_3
463-
// Convert the UNIX timestamp to a DateTimeOffset.
464-
properties.IssuedUtc = DateTimeOffset.FromUnixTimeSeconds((long) property.Value);
465-
#else
501+
// Note: the iat claim must be a numeric date value.
502+
// See https://tools.ietf.org/html/rfc7662#section-2.2
503+
// and https://tools.ietf.org/html/rfc7519#section-4.1.6 for more information.
504+
if (property.Value.Type != JTokenType.Float && property.Value.Type != JTokenType.Integer)
505+
{
506+
Logger.LogWarning("The 'iat' claim was ignored because it was not a decimal value.");
507+
508+
continue;
509+
}
510+
466511
properties.IssuedUtc = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero) +
467-
TimeSpan.FromSeconds((long) property.Value);
468-
#endif
512+
TimeSpan.FromSeconds((double) property.Value);
469513

470514
continue;
471515
}
472516

473517
case OAuthIntrospectionConstants.Claims.ExpiresAt:
474518
{
475-
#if NETSTANDARD1_3
476-
// Convert the UNIX timestamp to a DateTimeOffset.
477-
properties.ExpiresUtc = DateTimeOffset.FromUnixTimeSeconds((long) property.Value);
478-
#else
519+
// Note: the exp claim must be a numeric date value.
520+
// See https://tools.ietf.org/html/rfc7662#section-2.2
521+
// and https://tools.ietf.org/html/rfc7519#section-4.1.4 for more information.
522+
if (property.Value.Type != JTokenType.Float && property.Value.Type != JTokenType.Integer)
523+
{
524+
Logger.LogWarning("The 'exp' claim was ignored because it was not a decimal value.");
525+
526+
continue;
527+
}
528+
479529
properties.ExpiresUtc = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero) +
480-
TimeSpan.FromSeconds((long) property.Value);
481-
#endif
530+
TimeSpan.FromSeconds((double) property.Value);
482531

483532
continue;
484533
}
485534

486-
// Add the token identifier as a property on the authentication ticket.
487535
case OAuthIntrospectionConstants.Claims.JwtId:
488536
{
537+
// Note: the jti claim must be a string value.
538+
// See https://tools.ietf.org/html/rfc7662#section-2.2
539+
// and https://tools.ietf.org/html/rfc7519#section-4.1.7 for more information.
540+
if (property.Value.Type != JTokenType.String)
541+
{
542+
Logger.LogWarning("The 'jti' claim was ignored because it was not a string value.");
543+
544+
continue;
545+
}
546+
489547
properties.Items[OAuthIntrospectionConstants.Properties.TicketId] = (string) property;
490548

491549
continue;
492550
}
493551

494-
// Extract the scope values from the space-delimited
495-
// "scope" claim and store them as individual claims.
496-
// See https://tools.ietf.org/html/rfc7662#section-2.2
497552
case OAuthIntrospectionConstants.Claims.Scope:
498553
{
499-
var scopes = (string) property.Value;
554+
// Note: the scope claim must be a space-separated string value.
555+
// See https://tools.ietf.org/html/rfc7662#section-2.2
556+
// and https://tools.ietf.org/html/rfc7519#section-4.1.7 for more information.
557+
if (property.Value.Type != JTokenType.String)
558+
{
559+
Logger.LogWarning("The 'scope' claim was ignored because it was not a string value.");
560+
561+
continue;
562+
}
500563

501-
// Store the scopes list in the authentication properties.
564+
var scopes = ((string) property.Value).Split(
565+
OAuthIntrospectionConstants.Separators.Space,
566+
StringSplitOptions.RemoveEmptyEntries);
567+
568+
// Note: the OpenID Connect extensions require storing the scopes
569+
// as an array of strings, even if there's only element in the array.
502570
properties.Items[OAuthIntrospectionConstants.Properties.Scopes] =
503-
new JArray(scopes.Split(' ')).ToString(Formatting.None);
571+
new JArray(scopes).ToString(Formatting.None);
504572

505-
foreach (var scope in scopes.Split(' '))
573+
// For convenience, also store the scopes as individual claims.
574+
foreach (var scope in scopes)
506575
{
507576
identity.AddClaim(new Claim(property.Name, scope));
508577
}
509578

510579
continue;
511580
}
512581

513-
// Store the audience(s) in the ticket properties.
514-
// Note: the "aud" claim may be either a list of strings or a unique string.
515-
// See https://tools.ietf.org/html/rfc7662#section-2.2
516582
case OAuthIntrospectionConstants.Claims.Audience:
517583
{
518-
if (property.Value.Type == JTokenType.Array)
584+
// Note: the aud claim must be either a string value or an array of strings.
585+
// See https://tools.ietf.org/html/rfc7662#section-2.2
586+
// and https://tools.ietf.org/html/rfc7519#section-4.1.4 for more information.
587+
if (property.Value.Type == JTokenType.String)
519588
{
520-
var value = (JArray) property.Value;
521-
if (value == null)
522-
{
523-
continue;
524-
}
589+
// Note: the OpenID Connect extensions require storing the audiences
590+
// as an array of strings, even if there's only element in the array.
591+
properties.Items[OAuthIntrospectionConstants.Properties.Audiences] =
592+
new JArray((string) property.Value).ToString(Formatting.None);
525593

526-
properties.Items[OAuthIntrospectionConstants.Properties.Audiences] = value.ToString(Formatting.None);
594+
continue;
527595
}
528596

529-
else if (property.Value.Type == JTokenType.String)
597+
else if (property.Value.Type == JTokenType.Array)
530598
{
599+
// Ensure all the array values are valid strings.
600+
var audiences = (JArray) property.Value;
601+
if (audiences.Any(audience => audience.Type != JTokenType.String))
602+
{
603+
Logger.LogWarning("The 'aud' claim was ignored because it was not an array of strings.");
604+
605+
continue;
606+
}
607+
531608
properties.Items[OAuthIntrospectionConstants.Properties.Audiences] =
532-
new JArray((string) property.Value).ToString(Formatting.None);
609+
property.Value.ToString(Formatting.None);
610+
611+
continue;
533612
}
534613

614+
Logger.LogWarning("The 'aud' claim was ignored because it was not a string nor an array.");
615+
535616
continue;
536617
}
537618
}
538619

620+
// If the claim is not a known claim, add it as-is by
621+
// trying to determine what's the best claim value type.
539622
switch (property.Value.Type)
540623
{
541-
// Ignore null values.
542-
case JTokenType.None:
543-
case JTokenType.Null:
624+
case JTokenType.String:
625+
identity.AddClaim(new Claim(property.Name, (string) property.Value, ClaimValueTypes.String));
544626
continue;
545627

546-
case JTokenType.Array:
547-
{
548-
foreach (var item in (JArray) property.Value)
549-
{
550-
identity.AddClaim(new Claim(property.Name, (string) item));
551-
}
628+
case JTokenType.Integer:
629+
identity.AddClaim(new Claim(property.Name, (string) property.Value, ClaimValueTypes.Integer));
630+
continue;
552631

632+
case JTokenType.Float:
633+
identity.AddClaim(new Claim(property.Name, (string) property.Value, ClaimValueTypes.Double));
553634
continue;
554-
}
555635

556-
case JTokenType.String:
636+
case JTokenType.Array:
557637
{
558-
identity.AddClaim(new Claim(property.Name, (string) property.Value));
638+
// When the claim is an array, add the corresponding items
639+
// as individual claims using the name assigned to the array.
640+
foreach (var value in (JArray) property.Value)
641+
{
642+
switch (value.Type)
643+
{
644+
case JTokenType.None:
645+
case JTokenType.Null:
646+
continue;
647+
648+
case JTokenType.String:
649+
identity.AddClaim(new Claim(property.Name, (string) value, ClaimValueTypes.String));
650+
continue;
651+
652+
case JTokenType.Integer:
653+
identity.AddClaim(new Claim(property.Name, (string) value, ClaimValueTypes.Integer));
654+
continue;
655+
656+
case JTokenType.Float:
657+
identity.AddClaim(new Claim(property.Name, (string) value, ClaimValueTypes.Double));
658+
continue;
659+
660+
case JTokenType.Array:
661+
{
662+
// When the array element is itself a new array, serialize it as-it.
663+
identity.AddClaim(new Claim(property.Name, value.ToString(Formatting.None),
664+
OAuthIntrospectionConstants.ClaimValueTypes.JsonArray));
665+
666+
continue;
667+
}
668+
669+
default:
670+
{
671+
// When the array element doesn't correspond to a supported
672+
// primitive type (e.g a complex object), serialize it as-it.
673+
identity.AddClaim(new Claim(property.Name, value.ToString(Formatting.None),
674+
OAuthIntrospectionConstants.ClaimValueTypes.Json));
675+
676+
continue;
677+
}
678+
}
679+
}
559680

560681
continue;
561682
}
562683

563-
case JTokenType.Integer:
684+
default:
564685
{
565-
identity.AddClaim(new Claim(property.Name, (string) property.Value, ClaimValueTypes.Integer));
686+
// When the array element doesn't correspond to a supported
687+
// primitive type (e.g a complex object), serialize it as-it.
688+
identity.AddClaim(new Claim(property.Name, property.Value.ToString(Formatting.None),
689+
OAuthIntrospectionConstants.ClaimValueTypes.Json));
566690

567691
continue;
568692
}

src/AspNet.Security.OAuth.Introspection/OAuthIntrospectionMiddleware.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
using Microsoft.Extensions.Logging;
1616
using Microsoft.Extensions.Options;
1717
using Microsoft.IdentityModel.Protocols;
18-
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
1918

2019
namespace AspNet.Security.OAuth.Introspection
2120
{

0 commit comments

Comments
 (0)