2021年1月4日 星期一

[ASP.NET Core] Identity Server 4 – PKCE Authorization Code Flow

  ASP.NET Core   Identity Server 4   Authorization Code    PKCE  

 




Introduction


 

If our web application in on server side, e.q. SSR. We can use Authorization Code Flow to exchange an Authorization Code for tokens.

The flow is as following,

(Picture from auth0.com)

 

As we can see, the flow:

1.  Pass the Authorization Code thru URL/URL schema.

2.  Use Authorization Code + Client ID + Client Secret to get the id_token and access_token.

 

 

However the Authorization Code may be hacked, also in public client, such as native mobile application or SPA (single page application), the Client Secret is hardly to be protected.

So it is suggested to integrate PKCE (Proof Key for Code Exchange) with Authorization Code Flow.

 

The PKCE Authorization Code flow was specified in RFC7636 and its flow is as following,

 

 

In this tutorial, we will implement the PKCE Authorization Code Flow with cookie-based authorization that is based on Identity Server 4.

 

Here is the final result’s demo.

 

 

 

 

 

Environment


 

Docker 18.05.0-ce

.NET Core SDK 3.1.201

IdentityServer4 3.1.2

IdentityModel 4.0.0

 

 

 

Implement


 

The source code is on my Github.

 

PKCE Client configuration on Auth Server

 

Go to Idsrv4 project (Auth Server), and open InMemoryInitConfig.cs to set the below configuration on a new client:

 

 

InMemoryInitConfig.cs

 

Notice that there are some key settings:

 

Configuration

Description

Value

RedirectUris

The allowed Uri(s) to return Authorization Code or Tokens.

E.q. "https://client-app/signin-oidc"

RequireConsent

If enabled, the user will be redirected to the consent page after sign-in.

Boolean, default: true.

RequirePkce

Whether a proof key is a must for requesting Authorization Code.

Boolean, default: false.

AllowPlainTextPkce

Send a proof key by plain method.

(not recommended)

Boolean, default: false.

 

public static IEnumerable<Client> GetClients()
 {
            return new[]
            {
                new Client
                {
                    ClientId = "PkceCodeBackend",
                    ClientName = "PKCE Authorization code Client",
                    AllowedGrantTypes = GrantTypes.Code,
                    AccessTokenType = AccessTokenType.Jwt,
                    ClientSecrets = { new Secret("secret".Sha256()) },
                    RedirectUris = {
                        "https://localhost:5001/signin-oidc"
                    },
                    RequireConsent = true// If enable, will redirect to consent page after sign-in
                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        "MyBackendApi2"
                    },
                    AllowOfflineAccess = true,
                    RequirePkce = true,
                    AllowPlainTextPkce = false,

                    AccessTokenLifetime = AppSettingProvider.Global?.AccessToken?.DefaultAbsoluteExpiry ?? 3600,
                    RefreshTokenUsage = TokenUsage.OneTimeOnly, // Or ReUse
                    RefreshTokenExpiration = TokenExpiration.Sliding,
                    AbsoluteRefreshTokenLifetime = AppSettingProvider.Global?.RefreshToken?.DefaultAbsoluteExpiry ?? 360000,
                    SlidingRefreshTokenLifetime = AppSettingProvider.Global?.RefreshToken?.DefaultSlidingExpiry ?? 36000,

                    ClientClaimsPrefix = string.Empty,
                }
           }
 }

 

 

Enable cookie-based authentication and Authorization Code Flow on client

 

Startup.cs: ConfigureServices

public void ConfigureServices(IServiceCollection services)
        {
            // … skip

            services.AddAuthentication(options =>
                    {
                        options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; // "Cookies"
                        options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; // "Cookies"
                        options.DefaultChallengeScheme = "oidc";
                    })
                    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
                    .AddOpenIdConnect("oidc", options =>
                    {
                        const string CODE_VERIFIER_KEY = "code_verifier";
                        const string CODE_CHALLENGE_KEY = "code_challenge";
                        const string CODE_CHALLENGE_METHOD_KEY = "code_challenge_method";

                        // Get config values from AppSetting file
                        string oidcServerBaseUrl = appSettings?.Host.OidcServer;
                        bool isRequireHttpsMetadata = !string.IsNullOrEmpty(oidcServerBaseUrl) && oidcServerBaseUrl.StartsWith("https");
                        options.Authority = string.IsNullOrEmpty(oidcServerBaseUrl) ? "https://localhost:6001" : oidcServerBaseUrl;
                        options.RequireHttpsMetadata = isRequireHttpsMetadata;
                        options.MetadataAddress = $"{oidcServerBaseUrl}/.well-known/openid-configuration";
                        options.BackchannelHttpHandler = AuthMetadataUtils.GetHttpHandler();

                        options.ClientId = "PkceCodeBackend";
                        options.ClientSecret = "secret";
                        options.ResponseType = "code";
                        options.ResponseMode = "form_post";
                        options.CallbackPath = "/signin-oidc";

                        options.SaveTokens = true;
                        options.Scope.Add(appSettings?.AuthOptions?.Audience);
                        options.Scope.Add("offline_access"); // Get refresh token

                        options.Events.OnRedirectToIdentityProvider = context =>
                                    {
                                        // only modify requests to the authorization endpoint
                                        if (context.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication)
                                        {
                                            // generate code_verifier
                                            var codeVerifier = CryptoRandom.CreateUniqueId(32);

                                            // store codeVerifier for later use
                                            context.Properties.Items.Remove(CODE_VERIFIER_KEY);
                                            context.Properties.Items.Add(CODE_VERIFIER_KEY, codeVerifier);

                                            // create code_challenge
                                            string codeChallenge;
                                            using (var sha256 = SHA256.Create())
                                            {
                                                var challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
                                                codeChallenge = Base64Url.Encode(challengeBytes);
                                            }

                                            // add code_challenge and code_challenge_method to request
                                            context.ProtocolMessage.Parameters.Remove(CODE_CHALLENGE_KEY);
                                            context.ProtocolMessage.Parameters.Remove(CODE_CHALLENGE_METHOD_KEY);
                                            context.ProtocolMessage.Parameters.Add(CODE_CHALLENGE_KEY, codeChallenge);
                                            context.ProtocolMessage.Parameters.Add(CODE_CHALLENGE_METHOD_KEY, "S256");
                                        }

                                        return Task.CompletedTask;
                                    };

                        options.Events.OnAuthorizationCodeReceived = context =>
                        {
                            // only when authorization code is being swapped for tokens
                            if (context.TokenEndpointRequest?.GrantType == OpenIdConnectGrantTypes.AuthorizationCode)
                            {
                                // get stored code_verifier
                                if (context.Properties.Items.TryGetValue(CODE_VERIFIER_KEY, out var codeVerifier))
                                {
                                    // add code_verifier to token request
                                    context.TokenEndpointRequest.Parameters.Add(CODE_VERIFIER_KEY, codeVerifier);
                                }
                            }

                            return Task.CompletedTask;
                        };
                    });
        }

 

 

Now we can only add [Authorize] to the protected route(s) as following,

[Route("[controller]")]
[Authorize]
public class OpenIdController : Controller
{ }



This will result in redirecting to the Authentication prompt (sign-in page) in Auth Server.

 

 

Auth Server’s Sign-in and Consent pages

 

We can follow the idsrv4 template to create the sign-in and consent pages on Auth Server.

I refactored some of the codes from the template in my sample code.

 

Sign-in page

 

 

 

Consent page

 


How the flow steps through

 

I will show what happens during the authentication flow.


 

1.  When a user who is not signed in, the client application will first send Authorization Code request to Auth Server’s URL: /connect/authorize with a random Code Challenge (URL safe base64 string).





The /connect/authorize url contains the following parameters.


(Table 1.)

URL parameter

Value

Description

client_id

PkceCodeBackend

The Client Id

redirect_uri

https://localhost:5001/signin-oidc

 

response_type

code

 

Scope

openid profile MyBackendApi2 offline_access

 

code_challenge_method

S256

 

code_challenge

uc6gNUTWAnwgwAo4O3QCRxLw8fFniR36vrA_2WMPxQo

URL safe base64

response_mode

form_post

 

Nonce

637455834911251586.ZDNhN…

Nonce is used to associate a Client session with an ID Token. It serves as a token validation parameter to mitigate replay attacks. See OpenID Connect specification.

State

CfDJ8LH7J_bUBzNAlMjrmdBfJia0f…

State is to protect the end user from cross site request forgery (CSRF) attacks. See OAuth 2.0 protocol RFC6749.

x-client-SKU

ID_NETSTANDARD2_0

The metadata of Identity Server 4.

x-client-ver

5.5.0.0

The metadata of Identity Server 4.



Then Auth Server will redirect the user to /Account/Login?RedirectUrl= with the url parameter: RedirectUrl.

The RedirectUrl contains the URL encoded string as following.

If you decode the value, you will get exactly the same information as the Table 1.

URL parameter

Value

Description

ReturnUrl

%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3DPkceCodeBackend%26redirect_uri%3Dhttps%253A%252F%252F172.23.131.181%253A5001%252Fsignin-oidc%26response_type%3Dcode%26scope%3Dopenid%2520profile%2520MyBackendApi2%2520offline_access%26code_challenge_method%3DS256%26code_challenge%3Duc6gNUTWAnwgwAo4O3QCRxLw8fFniR36vrA_2WMPxQo%26response_mode%3Dform_post%26nonce%3D6374558349BAXebbSohHgE%26x-client-SKU%3DID_NETSTANDARD2_0%26x-client-ver%3D5.5.0.0 HTTP/1.1

URL encoded string



In this step, the user had been redirected to the login page of Auth Server.

2.  User entered his/her ID/PWD and submitted. The Auth Server authenticated the user and redirect to the consent page, then the user submitted on the consent page.
In this step, the following urls (inside red block) contains the same information as Table 1.




3.  Finally, the Auth Server sent back the Authorization Code thru the redirect_url: https://localhost:5001/signin-oidc.






And the user was redirected to the original url on client side: /OpenId/Login.





And client side used the Authorization Code + Code Verifier to get the access token thru Auth Server’s API: /connect/token.










 

Source Code

 

Github: KarateJB/AspNetCore.IdentityServer4.Sample

 

 

 

Reference


 

Authorization Code Flow with Proof Key for Code Exchange (PKCE)

 

 

 

 

 

沒有留言:

張貼留言