Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
OAuth PKCE #7734
  • Loading branch information
Tratcher committed Jun 5, 2019
commit 7840a630f225b61ce3ac3e7eb6a0c9ab06c4f36a
Original file line number Diff line number Diff line change
Expand Up @@ -186,16 +186,25 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
=> Context.ForbidAsync(SignInScheme);

/// <summary>
/// Creates a 32 bit random Id and Base64Url encodes it.
/// </summary>
/// <returns></returns>
protected virtual string GenerateUniqueId()
{
var bytes = new byte[32];
CryptoRandom.GetBytes(bytes);
return Base64UrlTextEncoder.Encode(bytes);
}

protected virtual void GenerateCorrelationId(AuthenticationProperties properties)
{
if (properties == null)
{
throw new ArgumentNullException(nameof(properties));
}

var bytes = new byte[32];
CryptoRandom.GetBytes(bytes);
var correlationId = Base64UrlTextEncoder.Encode(bytes);
var correlationId = GenerateUniqueId();

var cookieOptions = Options.CorrelationCookie.Build(Context, Clock.UtcNow);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public MicrosoftAccountOptions()
AuthorizationEndpoint = MicrosoftAccountDefaults.AuthorizationEndpoint;
TokenEndpoint = MicrosoftAccountDefaults.TokenEndpoint;
UserInformationEndpoint = MicrosoftAccountDefaults.UserInformationEndpoint;
UsePkse = true;
Scope.Add("https://graph.microsoft.com/user.read");

ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace Microsoft.AspNetCore.Authentication.OAuth
{
/// <summary>
/// Contains information used to perform the code exchange.
/// </summary>
public class OAuthCodeExchangeContext
{
/// <summary>
/// Initializes a new <see cref="OAuthCreatingTicketContext"/>.
/// </summary>
/// <param name="properties">The <see cref="AuthenticationProperties"/>.</param>
/// <param name="code">The code returned from the authorization endpoint.</param>
/// <param name="redirectUri">The redirect uri used in the authorization request.</param>
public OAuthCodeExchangeContext(AuthenticationProperties properties, string code, string redirectUri)
{
Properties = properties;
Code = code;
RedirectUri = redirectUri;
}

/// <summary>
/// State for the authentication flow.
/// </summary>
public AuthenticationProperties Properties { get; }

/// <summary>
/// The code returned from the authorization endpoint.
/// </summary>
public string Code { get; }

/// <summary>
/// The redirect uri used in the authorization request.
/// </summary>
public string RedirectUri { get; }
}
}
127 changes: 78 additions & 49 deletions src/Security/Authentication/OAuth/src/OAuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
Expand All @@ -21,6 +22,11 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
{
public class OAuthHandler<TOptions> : RemoteAuthenticationHandler<TOptions> where TOptions : OAuthOptions, new()
{
private const string CodeVerifierKey = "code_verifier";
private const string CodeChallengeKey = "code_challenge";
private const string CodeChallengeMethodKey = "code_challenge_method";
private const string CodeChallengeMethodS256 = "S256";

protected HttpClient Backchannel => Options.Backchannel;

/// <summary>
Expand Down Expand Up @@ -99,77 +105,84 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
return HandleRequestResult.Fail("Code was not found.", properties);
}

using (var tokens = await ExchangeCodeAsync(code, BuildRedirectUri(Options.CallbackPath)))
var codeExchangeContext = new OAuthCodeExchangeContext(properties, code, BuildRedirectUri(Options.CallbackPath));
using var tokens = await ExchangeCodeAsync(codeExchangeContext);

if (tokens.Error != null)
{
return HandleRequestResult.Fail(tokens.Error, properties);
}

if (string.IsNullOrEmpty(tokens.AccessToken))
{
if (tokens.Error != null)
return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
}

var identity = new ClaimsIdentity(ClaimsIssuer);

if (Options.SaveTokens)
{
var authTokens = new List<AuthenticationToken>();

authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
if (!string.IsNullOrEmpty(tokens.RefreshToken))
{
return HandleRequestResult.Fail(tokens.Error, properties);
authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
}

if (string.IsNullOrEmpty(tokens.AccessToken))
if (!string.IsNullOrEmpty(tokens.TokenType))
{
return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
}

var identity = new ClaimsIdentity(ClaimsIssuer);

if (Options.SaveTokens)
if (!string.IsNullOrEmpty(tokens.ExpiresIn))
{
var authTokens = new List<AuthenticationToken>();

authTokens.Add(new AuthenticationToken { Name = "access_token", Value = tokens.AccessToken });
if (!string.IsNullOrEmpty(tokens.RefreshToken))
{
authTokens.Add(new AuthenticationToken { Name = "refresh_token", Value = tokens.RefreshToken });
}

if (!string.IsNullOrEmpty(tokens.TokenType))
{
authTokens.Add(new AuthenticationToken { Name = "token_type", Value = tokens.TokenType });
}

if (!string.IsNullOrEmpty(tokens.ExpiresIn))
int value;
if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
{
int value;
if (int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value))
// https://www.w3.org/TR/xmlschema-2/#dateTime
// https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
authTokens.Add(new AuthenticationToken
{
// https://www.w3.org/TR/xmlschema-2/#dateTime
// https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx
var expiresAt = Clock.UtcNow + TimeSpan.FromSeconds(value);
authTokens.Add(new AuthenticationToken
{
Name = "expires_at",
Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
});
}
Name = "expires_at",
Value = expiresAt.ToString("o", CultureInfo.InvariantCulture)
});
}

properties.StoreTokens(authTokens);
}

var ticket = await CreateTicketAsync(identity, properties, tokens);
if (ticket != null)
{
return HandleRequestResult.Success(ticket);
}
else
{
return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
}
properties.StoreTokens(authTokens);
}

var ticket = await CreateTicketAsync(identity, properties, tokens);
if (ticket != null)
{
return HandleRequestResult.Success(ticket);
}
else
{
return HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties);
}
}

protected virtual async Task<OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri)
protected virtual async Task<OAuthTokenResponse> ExchangeCodeAsync(OAuthCodeExchangeContext context)
{
var tokenRequestParameters = new Dictionary<string, string>()
{
{ "client_id", Options.ClientId },
{ "redirect_uri", redirectUri },
{ "redirect_uri", context.RedirectUri },
{ "client_secret", Options.ClientSecret },
{ "code", code },
{ "code", context.Code },
{ "grant_type", "authorization_code" },
};

// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
if (context.Properties.Items.TryGetValue(CodeVerifierKey, out var codeVerifier))
{
tokenRequestParameters.Add(CodeVerifierKey, codeVerifier);
context.Properties.Items.Remove(CodeVerifierKey);
}

var requestContent = new FormUrlEncodedContent(tokenRequestParameters);

var requestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
Expand Down Expand Up @@ -241,15 +254,31 @@ protected virtual string BuildChallengeUrl(AuthenticationProperties properties,
var scopeParameter = properties.GetParameter<ICollection<string>>(OAuthChallengeProperties.ScopeKey);
var scope = scopeParameter != null ? FormatScope(scopeParameter) : FormatScope();

var state = Options.StateDataFormat.Protect(properties);
var parameters = new Dictionary<string, string>
{
{ "client_id", Options.ClientId },
{ "scope", scope },
{ "response_type", "code" },
{ "redirect_uri", redirectUri },
{ "state", state },
};

if (Options.UsePkse)
{
var codeVerifier = GenerateUniqueId();

// Store this for use during the code redemption.
properties.Items.Add(CodeVerifierKey, codeVerifier);

using var sha256 = SHA256.Create();
var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
var codeChallenge = WebEncoders.Base64UrlEncode(challengeBytes);

parameters[CodeChallengeKey] = codeChallenge;
parameters[CodeChallengeMethodKey] = CodeChallengeMethodS256;
}

parameters["state"] = Options.StateDataFormat.Protect(properties);

return QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, parameters);
}

Expand Down
6 changes: 6 additions & 0 deletions src/Security/Authentication/OAuth/src/OAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,11 @@ public override void Validate()
/// Gets or sets the type used to secure data handled by the middleware.
/// </summary>
public ISecureDataFormat<AuthenticationProperties> StateDataFormat { get; set; }

/// <summary>
/// Enables or disables the use of the Proof Key for Code Exchange (PKCE) standard. See https://tools.ietf.org/html/rfc7636.
/// The default value is `false` but derived handlers should enable this if their provider supports it.
/// </summary>
public bool UsePkse { get; set; } = false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,7 @@ private async Task HandleChallengeAsyncInternal(AuthenticationProperties propert
// https://tools.ietf.org/html/rfc7636
if (Options.UsePkse)
{
var verifierBytes = new byte[32];
CryptoRandom.GetBytes(verifierBytes);
var codeVerifier = WebEncoders.Base64UrlEncode(verifierBytes);
var codeVerifier = GenerateUniqueId();

// Store this for use during the code redemption. See RunAuthorizationCodeReceivedEventAsync.
properties.Items.Add(CodeVerifierKey, codeVerifier);
Expand Down Expand Up @@ -1123,6 +1121,7 @@ private async Task<AuthorizationCodeReceivedContext> RunAuthorizationCodeReceive
if (properties.Items.TryGetValue(CodeVerifierKey, out var codeVerifier))
{
tokenEndpointRequest.Parameters.Add(CodeVerifierKey, codeVerifier);
properties.Items.Remove(CodeVerifierKey);
}

var context = new AuthorizationCodeReceivedContext(Context, Scheme, Options, properties)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public void ConfigureServices(IServiceCollection services)
o.UserInformationEndpoint = "https://demo.identityserver.io/connect/userinfo";
o.ClaimsIssuer = "IdentityServer";
o.SaveTokens = true;
// o.UsePkse = true;
o.UsePkse = true;
// Retrieving user information is unique to each provider.
o.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "sub");
o.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
Expand Down
Loading