Skip to content

Commit

Permalink
Merge pull request #74 from akunzai/client-credentials-header
Browse files Browse the repository at this point in the history
Prefer to send the client credentials in Authorization header
  • Loading branch information
akunzai committed Apr 2, 2022
2 parents 0c89d67 + 3662777 commit 795d518
Show file tree
Hide file tree
Showing 13 changed files with 109 additions and 64 deletions.
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-reportgenerator-globaltool": {
"version": "5.0.4",
"version": "5.1.3",
"commands": [
"reportgenerator"
]
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to this project will be documented in this file.

## 2.4.0 (2022-04-02)

- [Prefer to send the client credentials in Authorization header](https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1)

## 2.3.1 (2021-11-14)

- Floating latest dependencies for .NET 6
Expand Down
37 changes: 22 additions & 15 deletions samples/OAuth2HttpClientSample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,42 @@ static void ConfigureAuthorizerOptions(IServiceProvider resolver, AuthorizerOpti
options.AccessTokenEndpoint = configuration.GetValue<Uri>("OAuth2:AccessTokenEndpoint");
options.ClientId = configuration["OAuth2:ClientId"];
options.ClientSecret = configuration["OAuth2:ClientSecret"];
options.SendClientCredentialsInRequestBody =
configuration.GetValue("OAuth2:SendClientCredentialsInRequestBody", false);
options.Credentials = new NetworkCredential(
configuration["OAuth2:Credentials:UserName"],
configuration["OAuth2:Credentials:Password"]);
options.Scopes = configuration.GetSection("OAuth2:Scopes").Get<IEnumerable<string>>();
options.OnError = (code, message) => Console.Error.Write($"ERROR: [${code}]: {message}");
}

var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
static void ConfigureHttpClient(HttpClient client)
{
var clientBuilder =
hostContext.Configuration.GetValue("OAuth2:GrantFlow", "ClientCredentials")
.Equals("ClientCredentials", StringComparison.OrdinalIgnoreCase)
? services.AddOAuth2HttpClient<OAuth2HttpClient, ClientCredentialsAuthorizer>(
ConfigureAuthorizerOptions)
: services.AddOAuth2HttpClient<OAuth2HttpClient, ResourceOwnerCredentialsAuthorizer>(
ConfigureAuthorizerOptions);
clientBuilder.ConfigureHttpClient(client => client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")));
}).Build();
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("OAuth2HttpClientSample", "1.0"));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}

var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
var clientBuilder =
hostContext.Configuration.GetValue("OAuth2:GrantFlow", "ClientCredentials")
.Equals("ClientCredentials", StringComparison.OrdinalIgnoreCase)
? services.AddOAuth2HttpClient<OAuth2HttpClient, ClientCredentialsAuthorizer>(
ConfigureAuthorizerOptions, builder => builder.ConfigureHttpClient(ConfigureHttpClient))
: services.AddOAuth2HttpClient<OAuth2HttpClient, ResourceOwnerCredentialsAuthorizer>(
ConfigureAuthorizerOptions, builder => builder.ConfigureHttpClient(ConfigureHttpClient));
clientBuilder.ConfigureHttpClient(ConfigureHttpClient);
}).Build();
var configuration = host.Services.GetRequiredService<IConfiguration>();

Console.WriteLine("Creating a client...");
var oauth2Client = host.Services.GetRequiredService<OAuth2HttpClient>();

Console.WriteLine("Sending a request...");
var response = await oauth2Client.HttpClient.GetAsync(configuration.GetValue<Uri>("OAuth2:ResourceEndpoint")).ConfigureAwait(false);
var response = await oauth2Client.HttpClient.GetAsync(configuration.GetValue<Uri>("OAuth2:ResourceEndpoint"))
.ConfigureAwait(false);

Console.WriteLine("Response data:");
var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
Console.WriteLine(data);


Console.WriteLine(data);
34 changes: 22 additions & 12 deletions samples/OAuthHttpClientSample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@
using Microsoft.Extensions.Hosting;

var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((hostContext, services) =>
{
services.AddOAuthHttpClient<OAuthHttpClient>((_, options) =>
.ConfigureServices((hostContext, services) =>
{
options.ClientCredentials = new OAuthCredential(
hostContext.Configuration["OAuth:ClientId"],
hostContext.Configuration["OAuth:ClientSecret"]);
options.TokenCredentials = new OAuthCredential(
services.AddOAuthHttpClient<OAuthHttpClient>((_, options) =>
{
options.ClientCredentials = new OAuthCredential(
hostContext.Configuration["OAuth:ClientId"],
hostContext.Configuration["OAuth:ClientSecret"]);
options.TokenCredentials = new OAuthCredential(
hostContext.Configuration["OAuth:TokenId"],
hostContext.Configuration["OAuth:TokenSecret"]);
options.SignedAsQuery = hostContext.Configuration.GetValue("OAuth:SignedAsQuery", false);
options.SignedAsBody = hostContext.Configuration.GetValue("OAuth:SignedAsBody", false);
}).ConfigureHttpClient(client => client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")));
}).Build();
options.SignedAsQuery = hostContext.Configuration.GetValue("OAuth:SignedAsQuery", false);
options.SignedAsBody = hostContext.Configuration.GetValue("OAuth:SignedAsBody", false);
}).ConfigureHttpClient(client =>
{
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("OAuthHttpClientSample", "1.0"));
});
}).Build();

var configuration = host.Services.GetRequiredService<IConfiguration>();

Expand All @@ -28,13 +31,20 @@
Console.WriteLine("Sending a request...");
var method = new HttpMethod(configuration.GetValue("Request:Method", HttpMethod.Get.Method));
var request = new HttpRequestMessage(method, configuration.GetValue<Uri>("Request:Uri"));
var accept = configuration["Request:Accept"];
if (!string.IsNullOrWhiteSpace(accept))
{
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept));
}

var body = configuration.GetSection("Request:Body").Get<IDictionary<string, string>>();
if (body != null)
{
request.Content = new FormUrlEncodedContent(body);
}

var response = await oauthClient.HttpClient.SendAsync(request).ConfigureAwait(false);

Console.WriteLine("Response data:");
var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
Console.WriteLine(data);
Console.WriteLine(data);
1 change: 1 addition & 0 deletions samples/OAuthHttpClientSample/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"Request": {
"Method": "GET",
"Accept": "application/json",
"Uri": "https://example.com/oauth/profile"
}
}
7 changes: 6 additions & 1 deletion samples/OAuthInteractiveConsoleAuthorizer/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using GSS.Authorization.OAuth;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -21,7 +22,11 @@
.PostConfigure(options => Validator.ValidateObject(options, new ValidationContext(options), true));
services.AddSingleton<IRequestSigner, HmacSha1RequestSigner>();
services.AddHttpClient<InteractiveConsoleAuthorizer>()
.ConfigureHttpClient(client => client.BaseAddress = context.Configuration.GetValue<Uri>("OAuth:BaseAddress"));
.ConfigureHttpClient(client =>
{
client.BaseAddress = context.Configuration.GetValue<Uri>("OAuth:BaseAddress");
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("OAuthInteractiveConsoleAuthorizer", "1.0"));
});
services.AddTransient<IAuthorizer>(resolver => resolver.GetRequiredService<InteractiveConsoleAuthorizer>());
}).Build();

Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<Nullable>enable</Nullable>
<Version>2.3.1</Version>
<Version>2.4.0</Version>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Release' ">
Expand Down
1 change: 1 addition & 0 deletions src/GSS.Authorization.OAuth2/AuthorizerDefaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace GSS.Authorization.OAuth2
{
public static class AuthorizerDefaults
{
public const string Basic = "Basic";
public const string Bearer = "Bearer";

public const string ClientId = "client_id";
Expand Down
7 changes: 7 additions & 0 deletions src/GSS.Authorization.OAuth2/AuthorizerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ public class AuthorizerOptions

[Required]
public string ClientSecret { get; set; } = default!;

/*
* send the client credentials in the request-body? (default: Authorization header)
*
* see https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1
*/
public bool SendClientCredentialsInRequestBody { get; set; }

public IEnumerable<string>? Scopes { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -35,21 +37,27 @@ protected AccessTokenAuthorizerBase(HttpClient client, IOptions<AuthorizerOption

public override async Task<AccessToken> GetAccessTokenAsync(CancellationToken cancellationToken = default)
{
var formData = new Dictionary<string, string>
{
[AuthorizerDefaults.ClientId] = Options.ClientId,
[AuthorizerDefaults.ClientSecret] = Options.ClientSecret
};
var formData = new Dictionary<string, string>();
if (Options.Scopes != null)
{
formData.Add(AuthorizerDefaults.Scope, string.Join(AuthorizerDefaults.ScopeSeparator, Options.Scopes));
}
if (Options.SendClientCredentialsInRequestBody)
{
formData.Add(AuthorizerDefaults.ClientId, Options.ClientId);
formData.Add(AuthorizerDefaults.ClientSecret, Options.ClientSecret);
}
PrepareFormData(formData);

using var request = new HttpRequestMessage(HttpMethod.Post, Options.AccessTokenEndpoint)
{
Content = new FormUrlEncodedContent(formData)
};
if (!Options.SendClientCredentialsInRequestBody)
{
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizerDefaults.Basic,
Convert.ToBase64String(Encoding.ASCII.GetBytes($"{Options.ClientId}:{Options.ClientSecret}")));
}
var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);

if (response.IsSuccessStatusCode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ private void ConfigureAuthroizerOptions(IServiceProvider resolver, AuthorizerOpt
options.AccessTokenEndpoint = Configuration.GetValue<Uri>("OAuth2:AccessTokenEndpoint");
options.ClientId = Configuration["OAuth2:ClientId"];
options.ClientSecret = Configuration["OAuth2:ClientSecret"];
options.SendClientCredentialsInRequestBody = true;
options.Credentials = new NetworkCredential(
Configuration["OAuth2:Credentials:UserName"],
Configuration["OAuth2:Credentials:Password"]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -15,35 +16,38 @@ public class ClientCredentialsAuthorizerTests : IClassFixture<AuthorizerFixture>
private readonly AuthorizerOptions _options;
private HttpStatusCode _errorStatusCode;
private string? _errorMessage;
private readonly string _basicAuthHeaderValue;

public ClientCredentialsAuthorizerTests(AuthorizerFixture fixture)
{
if (fixture.Configuration.GetValue("HttpClient:Mock", true))
{
_mockHttp = new MockHttpMessageHandler();
}

var services = fixture.BuildAuthorizer<ClientCredentialsAuthorizer>(_mockHttp, (code, s) =>
{
_errorStatusCode = code;
_errorMessage = s;
});
_authorizer = services.GetRequiredService<ClientCredentialsAuthorizer>();
_options = services.GetRequiredService<IOptions<AuthorizerOptions>>().Value;
_basicAuthHeaderValue =
$"Basic {Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_options.ClientId}:{_options.ClientSecret}"))}";
}

[Fact]
public async Task Authorizer_GetAccessToken_ShouldNotNull()
{
// Arrange
_mockHttp?.Expect(HttpMethod.Post, _options.AccessTokenEndpoint.AbsoluteUri)
.WithFormData(AuthorizerDefaults.ClientId, _options.ClientId)
.WithFormData(AuthorizerDefaults.ClientSecret, _options.ClientSecret)
.WithHeaders("Authorization", _basicAuthHeaderValue)
.WithFormData(AuthorizerDefaults.GrantType, AuthorizerDefaults.ClientCredentials)
.Respond("application/json", JsonSerializer.Serialize(new AccessToken
{
Token = Guid.NewGuid().ToString(),
ExpiresInSeconds = 10
}));
.Respond("application/json",
JsonSerializer.Serialize(new AccessToken
{
Token = Guid.NewGuid().ToString(), ExpiresInSeconds = 10
}));

// Act
var accessToken = await _authorizer.GetAccessTokenAsync().ConfigureAwait(false);
Expand All @@ -58,14 +62,13 @@ public async Task Authorizer_GetAccessToken_ShouldNotEmpty()
{
// Arrange
_mockHttp?.Expect(HttpMethod.Post, _options.AccessTokenEndpoint.AbsoluteUri)
.WithFormData(AuthorizerDefaults.ClientId, _options.ClientId)
.WithFormData(AuthorizerDefaults.ClientSecret, _options.ClientSecret)
.WithHeaders("Authorization", _basicAuthHeaderValue)
.WithFormData(AuthorizerDefaults.GrantType, AuthorizerDefaults.ClientCredentials)
.Respond("application/json", JsonSerializer.Serialize(new AccessToken
{
Token = Guid.NewGuid().ToString(),
ExpiresInSeconds = 10
}));
.Respond("application/json",
JsonSerializer.Serialize(new AccessToken
{
Token = Guid.NewGuid().ToString(), ExpiresInSeconds = 10
}));

// Act
var accessToken = await _authorizer.GetAccessTokenAsync().ConfigureAwait(false);
Expand All @@ -82,16 +85,15 @@ public async Task Authorizer_GetAccessTokenWithException_ShouldReturnNull()

// Arrange
_mockHttp.Expect(HttpMethod.Post, _options.AccessTokenEndpoint.AbsoluteUri)
.WithFormData(AuthorizerDefaults.ClientId, _options.ClientId)
.WithFormData(AuthorizerDefaults.ClientSecret, _options.ClientSecret)
.WithHeaders("Authorization", _basicAuthHeaderValue)
.WithFormData(AuthorizerDefaults.GrantType, AuthorizerDefaults.ClientCredentials)
.Respond(HttpStatusCode.InternalServerError);

// Act
var accessToken = await _authorizer.GetAccessTokenAsync().ConfigureAwait(false);

// Assert
Assert.Null(accessToken?.Token);
Assert.Null(accessToken.Token);
_mockHttp.VerifyNoOutstandingExpectation();
}

Expand All @@ -103,8 +105,7 @@ public async Task Authorizer_GetAccessTokenWithException_ShouldInvokeErrorHandle
// Arrange
var expectedErrorMessage = Guid.NewGuid().ToString();
_mockHttp.Expect(HttpMethod.Post, _options.AccessTokenEndpoint.AbsoluteUri)
.WithFormData(AuthorizerDefaults.ClientId, _options.ClientId)
.WithFormData(AuthorizerDefaults.ClientSecret, _options.ClientSecret)
.WithHeaders("Authorization", _basicAuthHeaderValue)
.WithFormData(AuthorizerDefaults.GrantType, AuthorizerDefaults.ClientCredentials)
.Respond(HttpStatusCode.InternalServerError, "application/json", expectedErrorMessage);

Expand All @@ -117,4 +118,4 @@ public async Task Authorizer_GetAccessTokenWithException_ShouldInvokeErrorHandle
_mockHttp.VerifyNoOutstandingExpectation();
}
}
}
}
Loading

0 comments on commit 795d518

Please sign in to comment.