ASP.NET
Core Identity
Server 4 Authorization
Code PKCE
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.
▋Docker 18.05.0-ce
▋.NET Core SDK 3.1.201
▋IdentityServer4 3.1.2
▋IdentityModel 4.0.0
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
▋Authorization
Code Flow with Proof Key for Code Exchange (PKCE)