|
7 | 7 | using System; |
8 | 8 | using System.Collections.Generic; |
9 | 9 | using System.Diagnostics; |
| 10 | +using System.IO; |
| 11 | +using System.Linq; |
10 | 12 | using System.Net.Http; |
11 | 13 | using System.Net.Http.Headers; |
12 | 14 | using System.Security.Claims; |
@@ -402,7 +404,34 @@ protected virtual async Task<JObject> GetIntrospectionPayloadAsync(string token) |
402 | 404 | return null; |
403 | 405 | } |
404 | 406 |
|
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 | + } |
406 | 435 | } |
407 | 436 |
|
408 | 437 | protected virtual bool ValidateAudience(AuthenticationTicket ticket) |
@@ -449,120 +478,215 @@ protected virtual async Task<AuthenticationTicket> CreateTicketAsync(string toke |
449 | 478 |
|
450 | 479 | foreach (var property in payload.Properties()) |
451 | 480 | { |
| 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. |
452 | 491 | switch (property.Name) |
453 | 492 | { |
454 | | - // Ignore the unwanted claims. |
| 493 | + // Always exclude the unwanted protocol claims. |
455 | 494 | case OAuthIntrospectionConstants.Claims.Active: |
456 | 495 | case OAuthIntrospectionConstants.Claims.TokenType: |
457 | 496 | case OAuthIntrospectionConstants.Claims.NotBefore: |
458 | 497 | continue; |
459 | 498 |
|
460 | 499 | case OAuthIntrospectionConstants.Claims.IssuedAt: |
461 | 500 | { |
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 | + |
466 | 511 | 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); |
469 | 513 |
|
470 | 514 | continue; |
471 | 515 | } |
472 | 516 |
|
473 | 517 | case OAuthIntrospectionConstants.Claims.ExpiresAt: |
474 | 518 | { |
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 | + |
479 | 529 | 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); |
482 | 531 |
|
483 | 532 | continue; |
484 | 533 | } |
485 | 534 |
|
486 | | - // Add the token identifier as a property on the authentication ticket. |
487 | 535 | case OAuthIntrospectionConstants.Claims.JwtId: |
488 | 536 | { |
| 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 | + |
489 | 547 | properties.Items[OAuthIntrospectionConstants.Properties.TicketId] = (string) property; |
490 | 548 |
|
491 | 549 | continue; |
492 | 550 | } |
493 | 551 |
|
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 |
497 | 552 | case OAuthIntrospectionConstants.Claims.Scope: |
498 | 553 | { |
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 | + } |
500 | 563 |
|
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. |
502 | 570 | properties.Items[OAuthIntrospectionConstants.Properties.Scopes] = |
503 | | - new JArray(scopes.Split(' ')).ToString(Formatting.None); |
| 571 | + new JArray(scopes).ToString(Formatting.None); |
504 | 572 |
|
505 | | - foreach (var scope in scopes.Split(' ')) |
| 573 | + // For convenience, also store the scopes as individual claims. |
| 574 | + foreach (var scope in scopes) |
506 | 575 | { |
507 | 576 | identity.AddClaim(new Claim(property.Name, scope)); |
508 | 577 | } |
509 | 578 |
|
510 | 579 | continue; |
511 | 580 | } |
512 | 581 |
|
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 |
516 | 582 | case OAuthIntrospectionConstants.Claims.Audience: |
517 | 583 | { |
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) |
519 | 588 | { |
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); |
525 | 593 |
|
526 | | - properties.Items[OAuthIntrospectionConstants.Properties.Audiences] = value.ToString(Formatting.None); |
| 594 | + continue; |
527 | 595 | } |
528 | 596 |
|
529 | | - else if (property.Value.Type == JTokenType.String) |
| 597 | + else if (property.Value.Type == JTokenType.Array) |
530 | 598 | { |
| 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 | + |
531 | 608 | properties.Items[OAuthIntrospectionConstants.Properties.Audiences] = |
532 | | - new JArray((string) property.Value).ToString(Formatting.None); |
| 609 | + property.Value.ToString(Formatting.None); |
| 610 | + |
| 611 | + continue; |
533 | 612 | } |
534 | 613 |
|
| 614 | + Logger.LogWarning("The 'aud' claim was ignored because it was not a string nor an array."); |
| 615 | + |
535 | 616 | continue; |
536 | 617 | } |
537 | 618 | } |
538 | 619 |
|
| 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. |
539 | 622 | switch (property.Value.Type) |
540 | 623 | { |
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)); |
544 | 626 | continue; |
545 | 627 |
|
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; |
552 | 631 |
|
| 632 | + case JTokenType.Float: |
| 633 | + identity.AddClaim(new Claim(property.Name, (string) property.Value, ClaimValueTypes.Double)); |
553 | 634 | continue; |
554 | | - } |
555 | 635 |
|
556 | | - case JTokenType.String: |
| 636 | + case JTokenType.Array: |
557 | 637 | { |
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 | + } |
559 | 680 |
|
560 | 681 | continue; |
561 | 682 | } |
562 | 683 |
|
563 | | - case JTokenType.Integer: |
| 684 | + default: |
564 | 685 | { |
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)); |
566 | 690 |
|
567 | 691 | continue; |
568 | 692 | } |
|
0 commit comments