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

Commit ca2c9ff

Browse files
committed
Update the introspection middleware to use IdentityModel's IConfigurationManager
1 parent 0115d43 commit ca2c9ff

14 files changed

Lines changed: 548 additions & 159 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using JetBrains.Annotations;
5+
using Microsoft.IdentityModel.Protocols;
6+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
7+
using Newtonsoft.Json;
8+
9+
namespace AspNet.Security.OAuth.Introspection
10+
{
11+
/// <summary>
12+
/// Represents an OAuth2 introspection configuration.
13+
/// </summary>
14+
[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
15+
public class OAuthIntrospectionConfiguration : OpenIdConnectConfiguration
16+
{
17+
/// <summary>
18+
/// Initializes a new instance of the <see cref="OAuthIntrospectionConfiguration"/> class.
19+
/// </summary>
20+
public OAuthIntrospectionConfiguration()
21+
: base() { }
22+
23+
/// <summary>
24+
/// Initializes a new instance of the <see cref="OAuthIntrospectionConfiguration"/> class.
25+
/// </summary>
26+
/// <param name="json">The JSON payload used to initialize the current instance.</param>
27+
public OAuthIntrospectionConfiguration([NotNull] string json)
28+
: base(json) { }
29+
30+
/// <summary>
31+
/// Gets or sets the introspection endpoint address.
32+
/// </summary>
33+
[JsonProperty(
34+
DefaultValueHandling = DefaultValueHandling.Ignore,
35+
NullValueHandling = NullValueHandling.Ignore,
36+
PropertyName = OAuthIntrospectionConstants.Metadata.IntrospectionEndpoint)]
37+
public string IntrospectionEndpoint { get; set; }
38+
39+
/// <summary>
40+
/// Represents a configuration retriever able to deserialize
41+
/// <see cref="OAuthIntrospectionConfiguration"/> instances.
42+
/// </summary>
43+
public class Retriever : IConfigurationRetriever<OAuthIntrospectionConfiguration>
44+
{
45+
/// <summary>
46+
/// Retrieves the OAuth2 introspection configuration from the specified address.
47+
/// </summary>
48+
/// <param name="address">The address of the discovery document.</param>
49+
/// <param name="retriever">The object used to retrieve the discovery document.</param>
50+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> that can be used to abort the operation.</param>
51+
/// <returns>An <see cref="OAuthIntrospectionConfiguration"/> instance.</returns>
52+
public async Task<OAuthIntrospectionConfiguration> GetConfigurationAsync(
53+
[NotNull] string address, [NotNull] IDocumentRetriever retriever, CancellationToken cancellationToken)
54+
{
55+
if (string.IsNullOrEmpty(address))
56+
{
57+
throw new ArgumentException("The address cannot be null or empty.", nameof(address));
58+
}
59+
60+
if (retriever == null)
61+
{
62+
throw new ArgumentNullException(nameof(retriever));
63+
}
64+
65+
return new OAuthIntrospectionConfiguration(await retriever.GetDocumentAsync(address, cancellationToken));
66+
}
67+
}
68+
}
69+
}

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

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -359,59 +359,25 @@ protected override async Task<bool> HandleUnauthorizedAsync(ChallengeContext con
359359
return false;
360360
}
361361

362-
protected virtual async Task<string> ResolveIntrospectionEndpointAsync(string issuer)
363-
{
364-
if (issuer.EndsWith("/"))
365-
{
366-
issuer = issuer.Substring(0, issuer.Length - 1);
367-
}
368-
369-
// Create a new discovery request containing the access token and the client credentials.
370-
var request = new HttpRequestMessage(HttpMethod.Get, issuer + "/.well-known/openid-configuration");
371-
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
372-
373-
var response = await Options.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
374-
if (!response.IsSuccessStatusCode)
375-
{
376-
Logger.LogError("An error occurred when retrieving the issuer metadata: the remote server " +
377-
"returned a {Status} response with the following payload: {Headers} {Body}.",
378-
/* Status: */ response.StatusCode,
379-
/* Headers: */ response.Headers.ToString(),
380-
/* Body: */ await response.Content.ReadAsStringAsync());
381-
382-
return null;
383-
}
384-
385-
var payload = JObject.Parse(await response.Content.ReadAsStringAsync());
386-
387-
var address = payload[OAuthIntrospectionConstants.Metadata.IntrospectionEndpoint];
388-
if (address == null)
389-
{
390-
return null;
391-
}
392-
393-
return (string) address;
394-
}
395-
396362
protected virtual async Task<JObject> GetIntrospectionPayloadAsync(string token)
397363
{
398-
// Note: updating the options during a request is not thread safe but is harmless in this case:
399-
// in the worst case, it will only send multiple configuration requests to the authorization server.
400-
if (string.IsNullOrEmpty(Options.IntrospectionEndpoint))
364+
var configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
365+
if (configuration == null)
401366
{
402-
Options.IntrospectionEndpoint = await ResolveIntrospectionEndpointAsync(Options.Authority);
367+
throw new InvalidOperationException("The OAuth2 introspection middleware was unable to retrieve " +
368+
"the provider configuration from the OAuth2 authorization server.");
403369
}
404370

405-
if (string.IsNullOrEmpty(Options.IntrospectionEndpoint))
371+
if (string.IsNullOrEmpty(configuration.IntrospectionEndpoint))
406372
{
407373
throw new InvalidOperationException("The OAuth2 introspection middleware was unable to retrieve " +
408-
"the provider configuration from the OAuth2 authorization server.");
374+
"the introspection endpoint address from the discovery document.");
409375
}
410376

411377
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Options.ClientId}:{Options.ClientSecret}"));
412378

413379
// Create a new introspection request containing the access token and the client credentials.
414-
var request = new HttpRequestMessage(HttpMethod.Post, Options.IntrospectionEndpoint);
380+
var request = new HttpRequestMessage(HttpMethod.Post, configuration.IntrospectionEndpoint);
415381
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
416382
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
417383

@@ -427,7 +393,7 @@ protected virtual async Task<JObject> GetIntrospectionPayloadAsync(string token)
427393
var response = await Options.HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
428394
if (!response.IsSuccessStatusCode)
429395
{
430-
Logger.LogError("An error occurred when validating an access token: the remote server " +
396+
Logger.LogError("An error occurred while validating an access token: the remote server " +
431397
"returned a {Status} response with the following payload: {Headers} {Body}.",
432398
/* Status: */ response.StatusCode,
433399
/* Headers: */ response.Headers.ToString(),

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

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
using Microsoft.Extensions.Caching.Distributed;
1515
using Microsoft.Extensions.Logging;
1616
using Microsoft.Extensions.Options;
17+
using Microsoft.IdentityModel.Protocols;
18+
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
1719

1820
namespace AspNet.Security.OAuth.Introspection
1921
{
@@ -28,14 +30,7 @@ public OAuthIntrospectionMiddleware(
2830
[NotNull] IDataProtectionProvider dataProtectionProvider)
2931
: base(next, options, loggerFactory, encoder)
3032
{
31-
if (string.IsNullOrEmpty(Options.Authority) &&
32-
string.IsNullOrEmpty(Options.IntrospectionEndpoint))
33-
{
34-
throw new ArgumentException("The authority or the introspection endpoint must be configured.", nameof(options));
35-
}
36-
37-
if (string.IsNullOrEmpty(Options.ClientId) ||
38-
string.IsNullOrEmpty(Options.ClientSecret))
33+
if (string.IsNullOrEmpty(Options.ClientId) || string.IsNullOrEmpty(Options.ClientSecret))
3934
{
4035
throw new ArgumentException("Client credentials must be configured.", nameof(options));
4136
}
@@ -74,6 +69,62 @@ public OAuthIntrospectionMiddleware(
7469

7570
Options.HttpClient.DefaultRequestHeaders.UserAgent.ParseAdd("ASP.NET Core OAuth2 introspection middleware");
7671
}
72+
73+
if (Options.ConfigurationManager == null)
74+
{
75+
if (Options.Configuration != null)
76+
{
77+
if (string.IsNullOrEmpty(Options.Configuration.IntrospectionEndpoint))
78+
{
79+
throw new ArgumentException("The introspection endpoint address cannot be null or empty.", nameof(options));
80+
}
81+
82+
Options.ConfigurationManager = new StaticConfigurationManager<OAuthIntrospectionConfiguration>(Options.Configuration);
83+
}
84+
85+
else
86+
{
87+
if (Options.Authority == null && Options.MetadataAddress == null)
88+
{
89+
throw new ArgumentException("The authority or an absolute metadata endpoint address must be provided.", nameof(options));
90+
}
91+
92+
if (Options.MetadataAddress == null)
93+
{
94+
Options.MetadataAddress = new Uri(".well-known/openid-configuration", UriKind.Relative);
95+
}
96+
97+
if (!Options.MetadataAddress.IsAbsoluteUri)
98+
{
99+
if (Options.Authority == null || !Options.Authority.IsAbsoluteUri)
100+
{
101+
throw new ArgumentException("The authority must be provided and must be an absolute URL.", nameof(options));
102+
}
103+
104+
if (!string.IsNullOrEmpty(Options.Authority.Fragment) || !string.IsNullOrEmpty(Options.Authority.Query))
105+
{
106+
throw new ArgumentException("The authority cannot contain a fragment or a query string.", nameof(options));
107+
}
108+
109+
if (!Options.Authority.OriginalString.EndsWith("/"))
110+
{
111+
Options.Authority = new Uri(Options.Authority.OriginalString + "/", UriKind.Absolute);
112+
}
113+
114+
Options.MetadataAddress = new Uri(Options.Authority, Options.MetadataAddress);
115+
}
116+
117+
if (Options.RequireHttpsMetadata && !Options.MetadataAddress.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
118+
{
119+
throw new ArgumentException("The metadata endpoint address must be a HTTPS URL when " +
120+
"'RequireHttpsMetadata' is not set to 'false'.", nameof(options));
121+
}
122+
123+
Options.ConfigurationManager = new ConfigurationManager<OAuthIntrospectionConfiguration>(
124+
Options.MetadataAddress.AbsoluteUri, new OAuthIntrospectionConfiguration.Retriever(),
125+
new HttpDocumentRetriever(Options.HttpClient) { RequireHttps = Options.RequireHttpsMetadata });
126+
}
127+
}
77128
}
78129

79130
protected override AuthenticationHandler<OAuthIntrospectionOptions> CreateHandler()

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

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
* concerning the license and the contributors participating to this project.
55
*/
66

7+
using System;
78
using System.Collections.Generic;
89
using System.Net.Http;
910
using Microsoft.AspNetCore.Authentication;
1011
using Microsoft.AspNetCore.Builder;
1112
using Microsoft.AspNetCore.DataProtection;
1213
using Microsoft.AspNetCore.Http.Authentication;
1314
using Microsoft.Extensions.Caching.Distributed;
15+
using Microsoft.IdentityModel.Protocols;
1416

1517
namespace AspNet.Security.OAuth.Introspection
1618
{
@@ -24,21 +26,37 @@ public OAuthIntrospectionOptions()
2426
}
2527

2628
/// <summary>
27-
/// Gets the intended audiences of this resource server.
28-
/// Setting this property is recommended when the authorization
29-
/// server issues access tokens for multiple distinct resource servers.
29+
/// Gets or sets the absolute URL of the OAuth2/OpenID Connect server.
30+
/// Note: this property is ignored when <see cref="Configuration"/>
31+
/// or <see cref="ConfigurationManager"/> are set.
3032
/// </summary>
31-
public ISet<string> Audiences { get; } = new HashSet<string>();
33+
public Uri Authority { get; set; }
3234

3335
/// <summary>
34-
/// Gets or sets the base address of the OAuth2/OpenID Connect server.
36+
/// Gets or sets the URL of the OAuth2/OpenID Connect server discovery endpoint.
37+
/// When the URL is relative, <see cref="Authority"/> must be set and absolute.
38+
/// Note: this property is ignored when <see cref="Configuration"/>
39+
/// or <see cref="ConfigurationManager"/> are set.
3540
/// </summary>
36-
public string Authority { get; set; }
41+
public Uri MetadataAddress { get; set; }
3742

3843
/// <summary>
39-
/// Gets or sets the address of the introspection endpoint.
44+
/// Gets or sets a boolean indicating whether HTTPS is required to retrieve the metadata document.
45+
/// The default value is <c>true</c>. This option should be used only in development environments.
46+
/// Note: this property is ignored when <see cref="Configuration"/> or <see cref="ConfigurationManager"/> are set.
4047
/// </summary>
41-
public string IntrospectionEndpoint { get; set; }
48+
public bool RequireHttpsMetadata { get; set; } = true;
49+
50+
/// <summary>
51+
/// Gets or sets the configuration used by the introspection middleware.
52+
/// Note: this property is ignored when <see cref="ConfigurationManager"/> is set.
53+
/// </summary>
54+
public OAuthIntrospectionConfiguration Configuration { get; set; }
55+
56+
/// <summary>
57+
/// Gets or sets the configuration manager used by the introspection middleware.
58+
/// </summary>
59+
public IConfigurationManager<OAuthIntrospectionConfiguration> ConfigurationManager { get; set; }
4260

4361
/// <summary>
4462
/// Gets or sets the client identifier representing the resource server.
@@ -57,6 +75,13 @@ public OAuthIntrospectionOptions()
5775
/// </summary>
5876
public string Realm { get; set; }
5977

78+
/// <summary>
79+
/// Gets the intended audiences of this resource server.
80+
/// Setting this property is recommended when the authorization
81+
/// server issues access tokens for multiple distinct resource servers.
82+
/// </summary>
83+
public ISet<string> Audiences { get; } = new HashSet<string>();
84+
6085
/// <summary>
6186
/// Gets or sets a boolean determining whether the access token should be stored in the
6287
/// <see cref="AuthenticationProperties"/> after a successful authentication process.
@@ -84,8 +109,7 @@ public OAuthIntrospectionOptions()
84109
public OAuthIntrospectionEvents Events { get; set; } = new OAuthIntrospectionEvents();
85110

86111
/// <summary>
87-
/// Gets or sets the HTTP client used to communicate
88-
/// with the remote OAuth2/OpenID Connect server.
112+
/// Gets or sets the HTTP client used to communicate with the remote OAuth2 server.
89113
/// </summary>
90114
public HttpClient HttpClient { get; set; }
91115

src/AspNet.Security.OAuth.Introspection/project.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,20 @@
3535
"JetBrains.Annotations": { "type": "build", "version": "10.1.4" },
3636
"Microsoft.AspNetCore.Authentication": "1.0.0",
3737
"Microsoft.Extensions.Caching.Abstractions": "1.0.0",
38+
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "2.0.0",
3839
"Newtonsoft.Json": "9.0.1"
3940
},
4041

4142
"frameworks": {
4243
"net451": { },
4344

44-
"netstandard1.3": {
45+
"netstandard1.4": {
4546
"dependencies": {
4647
"System.Dynamic.Runtime": "4.0.11"
4748
},
4849

4950
"imports": [
50-
"dotnet5.4",
51+
"dotnet5.5",
5152
"portable-net451+win8"
5253
]
5354
}

0 commit comments

Comments
 (0)