Protecting APIs and Users with OAuth and Asymmetric Authentication
/Introduction
OAuth2 dropped per-call cryptographic singing of requests in favor of easier to use, but arguably less secure access tokens. Users whose access tokens are intercepted can be impersonated as long as the token is valid. This would be much harder if every request had to be signed using asymmetric cryptography allowing resource servers to authenticate each API call before granting authorization.
While this may be cumbersome for some types of applications, it is trivial for applications that utilize blockchain networks as the ability to create and verify digital signatures using public key cryptography is innate to those applications.
This article demonstrated how adding asymmetric authentication to your OAuth2 flow improves both user experience and security.
Authentication/Authorization Flows
First, let us review a conventional OAuth2/OpenID Connect flow without asymmetric authentication:
Key
The advantage of OAuth2 with OpenID Connect is that user credentials do not flow through the web application but are only known to the identity provider which is completely separate from the web application and associated API. Additionally, any number of web applications can utilize the same identity provider for authentication and users can manager access grants for all those applications centrally in one place.
However, the only time the user is actually authenticated is when the user id and password is validated by the identity server. All subsequent requests to the web API are not authenticated and authorization is granted purely based on the presence of the JWT access token. If the access token is leaked or intercepted, then the user can be impersonated simply by including the token with forged requests.
Adding asymmetric authentication to the flow eliminates these shortcomings:
Key
There are several advantages of this flow over the previous OAuth2/OpenId Connect flow.
The user does not have to leave the web application in order to sign in - in fact there is no need to log in at all. When opening the web application, the user is authenticated automatically, and the full application and user data become available immediately.
Authorization works as before, but it is no longer possible to use leaked or intercepted access tokens for impersonation. Every call to the web API is authenticated. The access token alone is insufficient to gain access to API resources and should be more appropriately called "authorization token".
Signature validation is a compute-bound operation that does not create additional network traffic.
In short, adding Asymmetric Authentication to the OAuth flow improves both user experience and security without reducing scalability.
The flow can be further simplified if the id and authorization token (aka access token) are not needed for authorization, i.e. if authorization does not rely on user claims encoded in the tokens but is purely determined by the user's identity:
This flow is also very well suited for direct API access from command line clients:
Sample Implementation for .NET Web API and VueJS Web Client
In your ASP.NET Core Web API, import the ProDerivatives.AsymmetricAuthentication NuGet package. As we will be using Ethereum signatures, also import the ProDerivatives.Ethereum NuGet package.
Add below code to you Startup class:
Enable asymmetric authentication in ConfigureService method:
services.AddAuthentication(AsymmetricAuthenticationDefaults.AuthenticationScheme)
.AddAsymmetricAuthentication(options =>
{
options.SignatureTokenRetriever = TokenRetrieval.FromAuthenticationHeader();
options.SignatureValidator = VerifySignature();
});
Verify signature, in this case Ethereum signatures using ProDerivatives.Ethereum NuGet package:
private static Func<AuthenticationToken, string, bool> VerifySignature()
{
return (token, message) =>
{
var verifyFunction = ProDerivatives.Ethereum.Signer.VerifySignature();
return verifyFunction(token.Signature, token.PublicKey, message);
};
}
Optionally, also add bearer token validation with IdentityServer4. Simply specify authority and API name (aka audience):
services.AddAuthentication(AsymmetricAuthenticationDefaults.AuthenticationScheme)
.AddAsymmetricAuthentication(options =>
{
options.SignatureTokenRetriever = TokenRetrieval.FromAuthenticationHeader();
options.SignatureValidator = VerifySignature();
})
.AddIdentityServerAuthentication(options =>
{
options.Authority = Configuration["Settings:Authority"];
if (Configuration["ASPNETCORE_ENVIRONMENT"] == "Development")
options.RequireHttpsMetadata = false;
options.ApiName = "ProDerivatives";
});
If bearer tokens are used, then subject must match public key of signature.
The asymmetric authentication handler expects an authentication token in the header with following properties:
public class AuthenticationToken
{
public string Signature { get; set; }
public string PublicKey { get; set; }
public long Nonce { get; set; }
public DateTime Timestamp { get; set; }
}
In your web client, sign all requests to the web API by adding an authentication token to the request header. One simple way to create Ethereum signatures is to use the MetaMask browser plugin. Simply retrieve the connected account (i.e. Ethereum public key) and signatures from the injected ethereum object:
import { ethers } from "ethers"
class EthereumAuthentication {
constructor() {
if (typeof ethereum !== 'undefined') {
this._ethereum = window.ethereum
var provider = new ethers.providers.Web3Provider(this._ethereum)
this._signer = provider.getSigner()
}
}
getTicks() {
const d = new Date()
const n = d.getTime()
return n
}
async getAuthenticationHeader(action, url) {
const nonce = this.getTicks()
let message = `${nonce}|${action}|/${url}|`
const signerAddress = (await this._ethereum.request({ method: 'eth_accounts' }))[0]
const signature = await this._signer.signMessage(message)
const token = {
PublicKey: signerAddress,
Signature: signature,
Timestamp: new Date().toISOString(),
Nonce: nonce
}
const headers = {
AsymmetricAuthentication: JSON.stringify(token)
}
return headers
}
}
export default {
EthereumAuthentication
}
Finally, call API endpoints like so:
var header = await auth.getAuthenticationHeader("GET", path)
var result = (await this.$http.get(`${appBasePath}/${path}`, { headers: header })).body.value
If you would like to use authorization tokens in addition to signatures, then also add the bearer token obtained from IdenityServer to each request. Interceptors are a convenient way to set request headers:
Vue.http.interceptors.push((request, next) => {
const token = localStorage.getItem('user-token')
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
}
next()
})
Now all calls to the web API are authenticated and authorized using cryptographic signatures and bearer tokens, respectively.
Finally, in the VerifySignature method of the API server, it is highly recommended to also check that the nonce of the authentication token has been incremented in order to prevent replay attacks.