Let's start with a scenario. Bob the user has logged in to your ASP.NET Core application through Azure AD authentication. Bob then also opens their email through Office 365 in the same browser window. They decide to leave work for today, and sign out from Office 365.
Without single sign-out, Bob has to also sign out from your Core application for them to be fully signed out.
With single sign-out, Bob doesn't have to separately sign out from your application. It's already been done for them.
Defining the remote sign-out path
First you will need to define the RemoteSignoutPath
in the OpenIdConnectOptions
.
Here is the full configuration for OpenId Connect authentication that we will use:
.AddOpenIdConnect(o =>
{
o.ClientId = Configuration["Authentication:ClientId"];
o.Authority = Configuration["Authentication:Authority"];
o.CallbackPath = "/aad-callback";
o.RemoteSignOutPath = "/aad-signout";
});
Since the URL was defined as /aad-signout
and the app runs at https://mycoolapp.com
,
we will define the sign-out URL to Azure AD as https://mycoolapp.com/aad-signout
.
Here is how that looks:
Now one final thing is that due to how single sign-out works, we must remove the SameSite attribute from the authentication cookie:
.AddCookie(o =>
{
o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None;
})
This security feature must currently be removed to use single sign-out. I will explain why in a bit.
And we are done. If you try a scenario like in the start, you'll notice your authentication cookie will vanish.
The complete authentication configuration just for reference:
services
.AddAuthentication(o =>
{
o.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultForbidScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(o =>
{
o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None;
})
.AddOpenIdConnect(o =>
{
o.ClientId = Configuration["Authentication:ClientId"];
o.Authority = Configuration["Authentication:Authority"];
o.CallbackPath = "/aad-callback";
o.RemoteSignOutPath = "/aad-signout";
});
How it works
So, that was quite easy. The way it works is actually quite simple. Azure AD knows the user is logged in to your app, and it has a sign-out URL defined. AAD opens a hidden iframe and sets its URL to your sign-out URL.
Now this did not work for me at first due to the SameSite property that is set by default now in ASP.NET Core. The authentication cookie gets SameSite=lax by default, which means that it is passed only in GET requests that are top-level, when coming from another origin. So it will not be attached to requests from iframes or AJAX that are initiated by other sites.
Thus it must be disabled for this to work, since it works via an iframe that is hosted in another origin.
You can also use your app's normal signout URL in AAD (which you link to from a Sign Out link in your app), however it must then support GET requests. Based on my observations, it made AAD do the sign-out slightly faster as it was able to detect the iframe arriving in AAD's URL. Since the origin is the same, it is allowed access to the iframe URL and it knows the app signout is done. Otherwise it seems to wait some fixed time period.
This is known as front-channel global sign-out. There is also a back-channel global sign-out protocol specification, which AAD does not implement.
If you block framing through use of X-Frame-Options
or Content-Security-Policy
,
it will also prevent this from working.
You will have to allow https://login.microsoftonline.com
to frame your app.
Front-channel sign-out
This is the method used by Azure AD. Hidden iframe that gets set to your sign-out URL. Sign-out endpoint receives the GET request and handles sign-out as it normally would.
Pros:
- Easy to implement
- Can use existing signout URL with no modifications to app
Cons:
- Sort of a cross-request forgery
- Requires turning off SameSite mode for the auth cookie (if using cookies)
- Must allow AAD to frame your site (add login.microsoftonline.com as allowed frame-ancestor)
I would highly recommend having a CSP that blocks framing from other origins than AAD (plus others that you need to allow).
Back-channel sign-out
This is the other method for single sign-out defined in OpenId Connect specifications. It is not implemented by Azure AD. I have asked the Azure AD team about this and they did say they will look into it :)
This method works by having the identity provider send a request from its back-end to your back-end (i.e. using a back-channel). A signed token similar to an Id token is attached to the request so your back-end can know which user is signing out. It is up to your app to implement session invalidation then.
Pros:
- More secure, can implement strict controls for cookies and framing
- The call is authenticated, not based on a cookie, but a token signed by AAD
Cons:
- Harder to implement
- Even harder to implement correctly in a distributed application
You need to clear session state for the user when you get the request. Or you may need to mark the user's session as expired. And then check if the session is expired on every request.
I can definitely see the motivation for both of these flows. Front-channel is easy to implement, even existing apps can utilize it. Back-channel allows for a more secure approach for those apps willing to pay the cost of implementing it. I do wish AAD would implement the back-channel version too.
Summary
Overall, implementing OpenId Connect single sign-out has been made supremely easy in ASP.NET Core. Well, at least the front-channel version. Since Azure AD only supports front-channel single sign-out, it does require you to reduce some security controls such as removing the SameSite property from the authentication cookie. That protects from Cross-Site Request Forgery attacks, and honestly I'd like to keep that there. But since AAD needs to do an authenticated cross-site request, there is really no choice.
I think whether this feature is valuable enough to reduce the security controls in your app is a decision that the developers/architects will have to make on a case-by-case basis.
Thanks for reading!