As the first part of this series showed, the handling of tokens in browser applications is not entirely unproblematic. For this reason, an approach that has actually been known for a long time and that OAuth 2.0 Browser-based also describes has recently became popular: Server-side OAuth 2.0 and server-side Token handling.
To avoid this server-side logic from bleeding into our server-side APIs, we could encapsulate it in an reusable reverse proxy. I also call such an server an Authentication Gateway:
The idea is to use the best of both worlds: all calls from the client are tunneled through the gateway. This gateway takes care of obtaining and refreshing tokens and forwarding the tokens to the resource server (the Web API). However, all tokens remain at the gateway. The browser only gets an HTTP-only cookie that represents the user session at the gateway.
Since the tokens never end up in the browser, many of the attacks discussed do not apply. In addition, this approach drastically simplifies the implementation of the frontend, because the user is now authenticated without any frontend code.
In order to initiate (re)authentication or to log off the user, it is sufficient to be forwarded to a URL provided by the gateway. The front end can obtain information about the current user via a simple gateway service.
The Child Has Many Names
There are several names for the approach presented here: Some speak of forward authentication or a backend for frontend (BFF). I can't get used to the latter term in this context, especially since a backend for frontend is usually domain-specific and there isn't necessarily a 1:1 relationship between the authentication gateway and an BFF or the BFF and the underlying API. Or to put it another way: A BFF can, but does not have to, take on the tasks of an authentication gateway.
XSRF: Token or SameSite cookie?
There is still one small thing to solve: since we are now working with cookies again, we need to protect ourselves against XSRF attacks. Therefore, the gateway's session cookie should not only be HTTP-only, but also a SameSite cookie. Unfortunately, SameSite does not mean "Same Origin". In contrast to Origin, the site extends over all subdomains. So there is a risk that a less protected application, e.g. a simple CMS, on another subdomain becomes an issue.
It is therefore advisable to use an XSRF token as well. This is a random string issued by the gateway. The SPA must send this token back to the gateway on every API call. The gateway can thus ensure that the call originates from the user who was originally logged on.
This task can be automated in the SPA. In Angular, this even happens without any further action on the part of the developer. When Angular's HttpClient
sees a cookie named XSRF-TOKEN
, it automatically wraps it in an HTTP header named X-XSRF-TOKEN
on each and every call to its backend. Both names can be configured.
Implementation of a Gateway
Now that we have clarified the topic on a conceptual level, the question arises as to where we can get an implementation for an authentication gateway. Well, on the one hand there are numerous commercial solutions whose scope of services goes far beyond what is described here. Popular representatives are the gateway solutions from Kong and Traefik. In addition, there are also the large cloud providers with corresponding PaaS solutions: Azure Web Apps and Amazon API Gateway are two well-known examples of this. Also, Identity Server provides such a solution, called Backend for Frontend (BFF) Security Framework and there is an open-source implementation of this idea for quite a long time, called OAuth2 Proxy.
However, I'd like to present a more flexible approach, that allows to adopt the gateway for different authentication servers and to implement support for framework-specific features, like the XSRF token handling in Angular discussed above. For instance, the current implementation has been successfully tested with:
- Keycloak
- Auth0
- Identity Server
- Azure Active Directory
The gateway is based on YARP, a reverse proxy recently released by Microsoft. By the way, the acronym stands for "Yet Another Reverse Proxy". YARP is extremely lightweight and comes with many options out of the box, such as routing and load balancing, health checks and distributed tracing. The special thing about YARP, however, is that it can be expanded very easily, especially since it is technically based on ASP.NET Core.
Don't worry, if you have nothing to do with Microsoft and ASP.NET Core: .NET Core now runs on all major platforms (Windows, Linux, Mac) and the implementation is usually also "hidden" in a docker container. To adjust the behavior, you just need to know about the configuration file.
Another benefit of YARP is it can used together with all middleware components that are available for ASP.NET Core. This also applies to the OIDC middleware required for our purposes.
You can find the implementation of this gateway including a Docker file and example configurations for several identity solutions here.
The YARP configuration stored in appsettings.json
defines a route for the API and another route for the SPA:
{
"ReverseProxy": {
"routes": {
"apiRoute": {
"ClusterId": "apiCluster",
"AuthorizationPolicy": "authPolicy",
"match": {
"Path": "api/{**remainder}"
}
},
"appRoute": {
"ClusterId": "appCluster",
"AuthorizationPolicy": "authPolicy",
"match": {
"Path": "{**remainder}"
}
}
},
"clusters": {
"apiCluster": {
"destinations": {
"destination1": {
"Address": "http://demo.angulararchitects.io"
}
}
},
"appCluster": {
"destinations": {
"destination1": {
"Address": "http://localhost:4200"
}
}
}
}
}
}
The route for the API (apiRoute
) handles all requests whose paths start with api/
. The route appRoute
, which delegates to the SPA, takes care of all remaining requests. Both routes are each connected to a cluster. A cluster refers to one or more services that can serve the same requests. Therefore, they are key to the load balancing offered by YARP. However, since we want to focus on OAuth 2 and OIDC here, each cluster only refers to one address. Behind this is the API or SPA.
The configuration file also contains settings directly used by the Gateway and the OIDC middleware:
[...]
"Gateway": {
"SessionTimeoutInMin": "60",
"Url": "http://localhost:8080"
},
"Apis": [
{
"ApiPath": "/flight-api/",
},
{
"ApiPath": "/passenger-api/",
}
],
"OpenIdConnect": {
"Authority": "https://login.microsoftonline.com/e402[…]/v2.0",
"ClientId": "90c82e3f-[…]",
"ClientSecret": "fj67Q[…]",
"Scopes": "openid profile email offline_access api://flight-api/read-write",
"QueryUserInfoEndpoint": false
},
[...]
The section Gateway
defines the session timeout used for storing tokens and the URL the gateway uses.
The Apis
section defines all paths that YARP forwards to an API. For such calls, the gateway also includes an access token. If one does not exist or if it has already expired, it transparently carries out a token refresh.
The settings for the OpenId Connect middleware can be found under OpenIdConnect
. These key data can usually be found in the configuration of the authorization server. The Authority
is the URL of the authorization server. The Angular application should also be configured there as a client with a ClientId
and a ClientSecret
. The Scopes
define what the client is allowed to do on behalf of the user. The values openid profile email
determine that the client receives data of the logged-in user and offline_access
leads to the issuance of a refresh token according to OIDC.
The last scope, api://flight-api/read-write
, is use case specific and grants the client access to an API.
Authentication Code in the Frontend
Because this approach moves all the authentication logic and token handling into a reusable reverse-proxy, both, the server-side API but also the frontend code become far easier. The API only needs to check the access token. For this, most frameworks have already existing implementations that just need to be called or configured.
Also, the frontend code is quite straight forward:
@Injectable({
providedIn: 'root'
})
export class AuthService {
constructor(private http: HttpClient) { }
loadUserInfo(): Observable<unknown> {
return this.http.get<unknown>('/userinfo');
}
login(): void {
location.href = '/login';
}
logout(): void {
location.href = '/logout';
}
}
To get data about the current user, it just calls a special endpoint provided by the gateway. For logging-in (again) for for logging out, there are two additional Gateway endpoints the client forwards the user to. That's it!
Trying it out
In order to try it out, we just need to start the Gateway and navigate to http://localhost:8080. If you don't want to run it locally, you can use my example instance, deployed at https://demo-auth-gateway.azurewebsites.net.
After logging in, the client application requests information about the current user via the above mentioned endpoint. Also, when calling an API the access token is appended and, if needed, transparently refreshed:
You will never see the access token in the client. It remains in the server-side session. If you use your Browser's dev tools, you will only see an HTTP-only cookie that cannot be accessed via JavaScript. Hence, an attacker cannot steal it via JavaScript-based XSS.
More on Angular Security
More on this and other advanced security topics in the world of Angular can be found in our Angular Security Workshop with the international security expert Dr. Philippe De Ryck. It's 100% online and interactive with a mix of lectures, demos, quizzes, and hands-on labs.
All Details: Angular Security Workshop
Conclusion
Authentication gateways give us the best of both worlds: server-side OAuth 2 and OpenID Connect prevent some attacks better than what would be possible in the browser. In addition, these two established standards and token-based security give us a lot of flexibility. Integrating existing identity solutions and SSO are just a few examples.
The SPA only receives an HTTP-only cookie that cannot be stolen by JavaScript-based attacks. In addition, we can now protect ourselves very easily against XSRF attacks: Browsers support SameSite cookies and Angular picks up the XSRF tokens of the gateway without any further action.
It also drastically simplifies the implementation of the SPA. This can simply assume that the user will be authenticated by the gateway at the right time. In addition, the SPA gateway can also offer endpoints for an explicit login, logout and for retrieving user information.
We buy all these advantages with a little extra complexity for running and scaling the gateway. In addition, we now have to tunnel all calls through the gateway.
However, one problem remains: the danger of cross-site scripting ! The difference, however, is that when using a gateway, the attacker must tunnel their attack through the user's browser. Even if the attacker doesn't get their hands on the HTTP cookie, they can still make HTTP requests to the gateway. The browser includes the cookie so that the attacker appears on behalf of the user.
If the user closes the browser, however, the attack is over. This is not the case with the client-side use of tokens described at the beginning. Here the attacker picks up the token and can use it directly.