Skip to content

Commit

Permalink
Add PKCE support in OIDC & OAuth #7734 (#10928)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tratcher authored Jun 7, 2019
1 parent 4300f49 commit 75e0115
Show file tree
Hide file tree
Showing 14 changed files with 489 additions and 182 deletions.
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;
UsePkce = 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
Expand Up @@ -28,6 +28,20 @@ public OAuthChallengeProperties(System.Collections.Generic.IDictionary<string, s
public System.Collections.Generic.ICollection<string> Scope { get { throw null; } set { } }
public virtual void SetScope(params string[] scopes) { }
}
public partial class OAuthCodeExchangeContext
{
public OAuthCodeExchangeContext(Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, string code, string redirectUri) { }
public string Code { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public Microsoft.AspNetCore.Authentication.AuthenticationProperties Properties { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public string RedirectUri { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
}
public static partial class OAuthConstants
{
public static readonly string CodeChallengeKey;
public static readonly string CodeChallengeMethodKey;
public static readonly string CodeChallengeMethodS256;
public static readonly string CodeVerifierKey;
}
public partial class OAuthCreatingTicketContext : Microsoft.AspNetCore.Authentication.ResultContext<Microsoft.AspNetCore.Authentication.OAuth.OAuthOptions>
{
public OAuthCreatingTicketContext(System.Security.Claims.ClaimsPrincipal principal, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, Microsoft.AspNetCore.Http.HttpContext context, Microsoft.AspNetCore.Authentication.AuthenticationScheme scheme, Microsoft.AspNetCore.Authentication.OAuth.OAuthOptions options, System.Net.Http.HttpClient backchannel, Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse tokens, System.Text.Json.JsonElement user) : base (default(Microsoft.AspNetCore.Http.HttpContext), default(Microsoft.AspNetCore.Authentication.AuthenticationScheme), default(Microsoft.AspNetCore.Authentication.OAuth.OAuthOptions)) { }
Expand Down Expand Up @@ -64,7 +78,7 @@ public OAuthEvents() { }
[System.Diagnostics.DebuggerStepThroughAttribute]
protected virtual System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.AuthenticationTicket> CreateTicketAsync(System.Security.Claims.ClaimsIdentity identity, Microsoft.AspNetCore.Authentication.AuthenticationProperties properties, Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse tokens) { throw null; }
[System.Diagnostics.DebuggerStepThroughAttribute]
protected virtual System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse> ExchangeCodeAsync(string code, string redirectUri) { throw null; }
protected virtual System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.OAuth.OAuthTokenResponse> ExchangeCodeAsync(Microsoft.AspNetCore.Authentication.OAuth.OAuthCodeExchangeContext context) { throw null; }
protected virtual string FormatScope() { throw null; }
protected virtual string FormatScope(System.Collections.Generic.IEnumerable<string> scopes) { throw null; }
[System.Diagnostics.DebuggerStepThroughAttribute]
Expand All @@ -83,6 +97,7 @@ public OAuthOptions() { }
public System.Collections.Generic.ICollection<string> Scope { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
public Microsoft.AspNetCore.Authentication.ISecureDataFormat<Microsoft.AspNetCore.Authentication.AuthenticationProperties> StateDataFormat { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public string TokenEndpoint { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool UsePkce { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public string UserInformationEndpoint { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public override void Validate() { }
}
Expand Down
39 changes: 39 additions & 0 deletions src/Security/Authentication/OAuth/src/OAuthCodeExchangeContext.cs
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="OAuthCodeExchangeContext"/>.
/// </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; }
}
}
31 changes: 31 additions & 0 deletions src/Security/Authentication/OAuth/src/OAuthConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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>
/// Constants used in the OAuth protocol
/// </summary>
public static class OAuthConstants
{
/// <summary>
/// code_verifier defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeVerifierKey = "code_verifier";

/// <summary>
/// code_challenge defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeChallengeKey = "code_challenge";

/// <summary>
/// code_challenge_method defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeChallengeMethodKey = "code_challenge_method";

/// <summary>
/// S256 defined in https://tools.ietf.org/html/rfc7636
/// </summary>
public static readonly string CodeChallengeMethodS256 = "S256";
}
}
125 changes: 76 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,7 @@ namespace Microsoft.AspNetCore.Authentication.OAuth
{
public class OAuthHandler<TOptions> : RemoteAuthenticationHandler<TOptions> where TOptions : OAuthOptions, new()
{
private static readonly RandomNumberGenerator CryptoRandom = RandomNumberGenerator.Create();
protected HttpClient Backchannel => Options.Backchannel;

/// <summary>
Expand Down Expand Up @@ -99,77 +101,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))
{
return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
}

var identity = new ClaimsIdentity(ClaimsIssuer);

if (Options.SaveTokens)
{
if (tokens.Error != null)
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(OAuthConstants.CodeVerifierKey, out var codeVerifier))
{
tokenRequestParameters.Add(OAuthConstants.CodeVerifierKey, codeVerifier);
context.Properties.Items.Remove(OAuthConstants.CodeVerifierKey);
}

var requestContent = new FormUrlEncodedContent(tokenRequestParameters);

var requestMessage = new HttpRequestMessage(HttpMethod.Post, Options.TokenEndpoint);
Expand Down Expand Up @@ -241,15 +250,33 @@ 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.UsePkce)
{
var bytes = new byte[32];
CryptoRandom.GetBytes(bytes);
var codeVerifier = Base64UrlTextEncoder.Encode(bytes);

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

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

parameters[OAuthConstants.CodeChallengeKey] = codeChallenge;
parameters[OAuthConstants.CodeChallengeMethodKey] = OAuthConstants.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 UsePkce { get; set; } = false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public OpenIdConnectOptions() { }
public Microsoft.AspNetCore.Authentication.ISecureDataFormat<Microsoft.AspNetCore.Authentication.AuthenticationProperties> StateDataFormat { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public Microsoft.AspNetCore.Authentication.ISecureDataFormat<string> StringDataFormat { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public Microsoft.IdentityModel.Tokens.TokenValidationParameters TokenValidationParameters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool UsePkce { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public bool UseTokenLifetime { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute]set { } }
public override void Validate() { }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"SocialSample": {
"OpenIdConnectSample": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,22 @@ public void ConfigureServices(IServiceCollection services)
.AddCookie()
.AddOpenIdConnect(o =>
{
/*
o.ClientId = Configuration["oidc:clientid"];
o.ClientSecret = Configuration["oidc:clientsecret"]; // for code flow
o.Authority = Configuration["oidc:authority"];
*/
// https://github.com/IdentityServer/IdentityServer4.Demo/blob/master/src/IdentityServer4Demo/Config.cs
o.ClientId = "server.hybrid";
o.ClientSecret = "secret"; // for code flow
o.Authority = "https://demo.identityserver.io/";

o.ResponseType = OpenIdConnectResponseType.CodeIdToken;
o.SaveTokens = true;
o.GetClaimsFromUserInfoEndpoint = true;
o.AccessDeniedPath = "/access-denied-from-remote";

o.ClaimActions.MapAllExcept("aud", "iss", "iat", "nbf", "exp", "aio", "c_hash", "uti", "nonce");
// o.ClaimActions.MapAllExcept("aud", "iss", "iat", "nbf", "exp", "aio", "c_hash", "uti", "nonce");

o.Events = new OpenIdConnectEvents()
{
Expand Down
Loading

0 comments on commit 75e0115

Please sign in to comment.