Auth Series 3: SSO

by cthos
4323 words


Intro image showing the logos of SAML, OAuth, and OIDC

Right! Welcome back to the third post in the Authentication Post series and this time we're going to talk about single sign on (SSO) and other federated identity protocols.

I'll try to keep this post under the word count of the previous two, but like many auth things… this could be a lengthy topic.

In the previous posts in the series we covered how to authenticate users with a username and password on a single service, and how to add multifactor authentication to that account. What happens if you want to create another application? One option you can choose is to simply make a second user base for that application. That might be the right choice if you want those user bases to be completely separated from one another. But what if the two applications you've created are closely linked together? You could copy your existing user database to the new service, or link both services to the same database, but now you're having to manage the complexity of the user experience between two different sites. FIDO/U2F/Passkeys don't work cross-domain either, so you might be dealing with the complexities around that as well.

Plus, the users will have to enter their credentials on both sites any time they want to use them at the same time.

SSO solutions were built to handle these kinds of use-cases, allowing a user to log into multiple sites with a single set of credentials while minimizing the number of interactions the end user needs to perform. There have been many iterations of this over the years, so I'll give you a brief (non-exhaustive) history of some options and where they've gone.

One common option in the "olden days" was to have a single parent domain where you authenticated the user, and then used 3rd party cookies in order to trigger the authentication on multiple target sites by granting those sites access to that cookie. This was really common when you had a root parent domain (example.com) and your additional sites were all subdomains of that first site (service1.example.com, service2.example.com) because it was relatively easy to set a cookie that's readable on all those domains. You'd then wire the backend services to the parent domain for handling account actions (meaning you'd land on example.com to change your password, for example). The good news is you can still do that, so long as you set the domain property of the cookie to that parent domain, subdomains can still read that cookie.

But what if your authentication system is on a different domain as the services (one real life example of this is google.com vs youtube.com and how they do SSO on that is actually pretty fun)? Well, for a long while you could do something like an AJAX request to the backend service and have that service set a cookie that could be read on any domain. That's known as a 3rd party cookie. You might have heard a lot about those, because they're also used for surveillance capitalism and serving you ads. The browser vendors have been slowly blocking them by default for years (though Chrome apparently is walking back their plans because it'd impact their ad revenue). So that technique no longer works for auth because ad vendors are... well... I'll stop there.

😈 Fun fact, when you sign into Google it also issues a 302 redirect to Youtube to sign you in on Youtube to get around the cross-domain problem. (source)

Another fun element of the rise of social media platforms was the rise of using a social account to log into another service. Many services today offer the ability to log into their services using your Google, Facebook, (rip) Twitter, and etc. accounts in lieu of signing up for yet another website. This has some benefits for the end user, you don't need to remember yet another password for the service, you can just click on the "sign in with Google" button, asked to share some information, and then you're logged in! Easy.

The downside, of course, is you're locked into the social provider's whims, and service providers generally only bothered to support the biggest players (this was especially true before OAuth 2 / OIDC).

Anyhow, in this model you can't just rely on setting cookies willy nilly, that'd be insecure as all get out (you don't want every website on the internet having unrestricted access to your Google information — only Google Ads get to do that), so you need some sort of protocol to handle the exchange of information, first to ensure that the site in question is allowed to even ask you for your account information and a mechanism for securely sharing the bits you consent to sending back to the service.

That's OAuth (and now OIDC... I'll explain shortly)!

There are a bunch of other ways you can do authentication against parties you don't control, including:

  • SAML (We'll talk about this)
  • Kerberos
  • LDAP
  • RADIUS

A lot of these are context-dependent. For example, I've personally seen more SAML integrations with Universities than I've seen of the other protocols. Kerberos is pretty common in Enterprises (combined with Active Directory often alongside an LDAP system).

If you own the system, why use a protocol?

Permalink to “If you own the system, why use a protocol?”

So, like I mentioned above, using a combination of cookies and sorcery, you could build your own authentication systems with username and password authentication that works across a number of different properties you control. I'm going to recommend you do not do that. I strongly recommend that if you're going to build a centralized authentication system, you use a protocol (and I'm going to go further and say you use OAuth2 / OIDC). This is because it makes it a lot easier to implement across those sites. There will be libraries that you don't have to write yourself. The pathways are well-known. The cognitive overhead is smaller. If you want to let a third-party into your system you don't have to do something bespoke.

It'll save you a lot of headache after the initial headache of understanding how the heck these protocols work. Walk with me and I'll help you out.

SAML 2.0 - a versatile and complicated protocol

Permalink to “SAML 2.0 - a versatile and complicated protocol”

Carlo Toffolo @ Shutterstock #795590020
Carlo Toffolo @ Shutterstock #795590020

I don't want to spend a lot of time on SAML, but it's near and dear to my heart ever since I spent a full week reverse-engineering SimpleSAMLphp and reading the spec until my mind exploded. There are some similarities to how OAuth and OIDC handle the cross-site communications though, so it's worth discussing.

SAML is the "Security Assertion Markup Language", and by that it means it's XML. It's a lot of XML. Every bit of the communication protocol is sending large blobs of XML back and forth. The way this works is essentially this process for Service Provider (SP) initiated login:

😈 Before any of this can work the service provider and the identity provider must be configured to allow this. Namely, each side of the exchange must set up some configuration options to identify themselves to each other (otherwise any random site on the internet could try to trick you into signing into the identity provider).

Also, I'm going to use "service provider" through the rest of this post, but OAuth tends to call them "Relying Parties" or simply "the client".
  1. The SP, example.com, issues an <samlp:AuthnRequest> to the Identity Provider (IdP) greatlogins.test by a HTTP GET or HTTP POST request in the SAMLRequest param (this XML is deflated and base64 encoded to fit in that param).
    1. There's also an Artifact Binding which uses SOAP and a reference ID to allow a lookup rather than send the whole response but I've literally never seen this implemented in the wild. Not a single time. Have you?
  2. The IdP base64 decodes and inflates the AuthnRequest and validates it contains what it expects. Namely, that the SP is on its Allow List, and that it has been signed (and optionally encrypted) correctly by that SP and the request has not been tampered with.
    1. Yeah, setting up a SAML connection requires sharing certificates ahead of time. SAML Requests are signed, and optionally encrypted.
  3. The user is shown a login screen where they enter whatever credentials they need to (the SP doesn't need to care how this happens).
  4. The user is redirected back to the SP (again via a GET or a POST) which contains a <samlp:AuthnResponse> in the (you guessed it) SAMLResponse param. This too is deflated and base64 encoded.
  5. The SP validates the response in the same way the IdP did (ensuring the response has not been tampered with) and then pulls attributes out of the response to make authentication / authorization decisions based on that. These attributes are configurable, and are usually a thing you want to negotiate when doing the setup process.
😈 Why did I use a .test there? Because .test is a reserved TLD and there's no real website that could resolve to! (Like example.com)

Did I make that sound easy? Well there are a lot of things that can go wrong, but the good news is those are relatively predictable if you know how XMLSec works... Right. Yeah. Okay, so tl;dr it's usually a problem with configuring the shared secrets or missing response attributes. It could be other things, but it's there.

One major thing you're going to run into is there still aren't a lot of deep tutorials (that I'm aware of) about SAML. Here's a decent one.

That's all I want to say about SAML right now, but I did want to include it so you can see the similarities in...

Also known as "the precious". This is my most preferred SSO protocol these days, not least of which is because of breadth and depth. There are a number of software packages that support these two protocols. Doing simple implementations is very straightforward, but there's a lot of configuration and security features. There's even considerations for signing in to smart devices that don't have a web browser. There are definitely rough edges, but, it's the one that comes close to being the best we've got for the most people.

Like I mentioned in the history lesson, OAuth was originally created to allow users to grant 3rd parties access to a social account in order to do things on their behalf. This varied, but it was usually in the context of "allow this site to post as you on social media" to enable various kinds of experiences. While OAuth by itself grants authorization rather than authentication, a lot of folks were using it for both. Namely, if you're issued a token that can act on behalf of a social account, isn't that proof enough of authentication?

😈 No! But people were using it that way anyhow.

That's where OIDC comes into the picture. OIDC (or Open ID Connect) provides a series of protocols for authentication and it's often paired with OAuth 2.0 for authorization. This, I think, is why you hear folks use the terms basically interchangeably these days. I've been caught doing that very thing because for most audiences the distinction doesn't matter.

That out of the way, here's how a typical flow with OIDC works (try to spot the similarities to SAML). Now, there are several OAuth grant types and flows that you can and should be using which I will get into, but we'll start with the simplest one that's intended for end users: response_type=token (which is the deprecated Implicit grant). You should not use this grant type any longer, but it's the simplest version, and we'll use it to build your knowledge.

😈 The actual simplest one is client_credentials which involves sending a client_id and client_secret in exchange for a token. It's intended for machine-to-machine use cases, not for end users.

Like SAML, the IdP must be configured with knowledge of the SP (in the form of an "Application") which grants the SP a client_id and the SP tells the IdP what URLs it's allowed / expected to redirect you to.

😈 There's such a thing as client autoregistration, which is a thing that Mastodon uses, which allows you to just create a client on the fly. I... don't really like it but in some cases it's necessary. Like Mastodon.

The Implicit flow works like this:

  1. The SP redirects the user to the IdP's login endpoint (optionally autodiscovering this URL by querying /.well-known/openid-configuration on the IdP domain), including the client_id, response_type (token for this example), scopes (what information you want to request), state which is an identifier for your application to manage its "state", and the redirect_uri.
  2. The user is prompted to log into the IdP. Assume they do so.
  3. The user is shown a screen that shows the application that is requesting the user's information, along with an enumerated list of what information the application is requesting.
  4. Assuming the user says "yes go go go", the IdP redirects to the redirect_uri (assuming it's on the allowed list) along with a token parameter (and a refresh token to get a new one when that token expires).
  5. The Application can just use the token to then get information about the user from the IdP. Simple!

Now, the implicit flow is fairly vulnerable to access token leakage and token replay attacks, and they can't be bound to a given client. Instead, what you should be using is the Authorization Code flow, with PKCE. Let's build on the Implicit flow and add Authorization Code.

Here's how that looks:

  1. The SP redirects to the IdP in the same way as in the implicit flow, but instead response_type=authorization_code.
  2. Same
  3. Same
  4. The IdP redirects back with an authorization_code which is short-lived (usually 30 seconds).
  5. The SP makes a call to the token endpoint with that authorization_code from the backend along with its client_secret. Since an attacker won't have that secret, they cannot exchange the code for a token, preventing some leakage issues. The timeout prevents replay attacks.
    1. You can configure a public client that doesn't need the secret to do the exchange.
  6. The IdP returns the token to the SP, and marks the authorization code as used (though some providers allow you to configure how many times the code's valid to prevent weird race condition problems).

You might be noticing an issue here, if you're familiar with mobile apps. If you're authenticating directly from a device, the client must be public. There's no way to keep a secret a secret in a mobile app. Someone could decompile it, or otherwise coerce the information from the device (This is also true of Single Page Apps). So, how do we make those calls a bit more secure? PKCE!

😈 Sidebar: PKCE is also useful for apps with client secrets to prevent CSRF attacks.

Let's build upon our previous flow.

  1. The response type remains the same, but in this case the SP creates a code_verifier which to quote oauth.com: "This is a cryptographically random string using the characters A-Z, a-z, 0-9, and the punctuation characters -._~ (hyphen, period, underscore, and tilde), between 43 and 128 characters long."
  2. The SP makes a SHA-256 hash of the code_verifier which is then base64 encoded and sent along with the request. This is the code_challenge
  3. Proceed along until step 5 of the previous step - this time, instead of sending just the code, you also send the code_verifier along with the request.
  4. The IdP uses the code_verifier to generate a code_challenge and checks to see if that matches the code_challenge it received in step #2. If it does, it can be assured that the request hasn't been intercepted partway through since only the client had that information.
  5. The IdP returns tokens as established.

And that's PKCE in a nutshell.

vector zefirka @ Shutterstock #1936423573
vector zefirka @ Shutterstock #1936423573

Right, so I've said "the IdP returns tokens" a number of times now, and while that's accurate there's some more detail that we need to cover. What is a token?

It could be several things, turns out, but at it's most basic in OAuth parlance it's a "Bearer" token which allows access to resources on a 3rd party when sent along with the request. A lot of services will return an opaque token which is just an internal identifier to the IdP and means nothing outside of that. It could also be a JSON Web Token (JWT) that is an encoded and (usually) signed token that contains information about said token.

Here's an example of a (unsigned) JWT Access token from Auth0:

{
"iss": "https://my-domain.auth0.com/",
"sub": "auth0|123456",
"aud": [
"https://example.com/health-api",
"https://my-domain.auth0.com/userinfo"
],
"azp": "my_client_id",
"exp": 1311281970,
"iat": 1311280970,
"scope": "openid profile read:patients read:admin"
}
😈 For Access tokens Auth0 recommends treating them as opaque always, regardless of format. That is to say they expect you to call endpoints with the token, not introspect it.

There are also refresh tokens, which are how you keep a user logged in over an extended period of time by exchanging them for new access tokens (and ideally rotating them) and ID Tokens.

Let's look at the latter token.

It's worth calling out at this point that OIDC endpoints (remember how I mentioned that OIDC is concerned with authentication while OAuth is concerned with authorization?) can also return an id_token which will contain information about the user along with a series of claims (depending on which scopes you send along with the initial request).

In order to get this token, you have to pass the openid scope in the first request, and it'll return the id_token along with the access and refresh tokens. The id_token is always a JWT, and it should be cryptographically signed by the JSON Web Key (JWK) that's present in the /.well-known/openid-configuration endpoint I mentioned above.

Here's an example Auth0 ID Token (without the signature bits):

{
"iss": "http://my-domain.auth0.com",
"sub": "auth0|123456",
"aud": "my_client_id",
"exp": 1311281970,
"iat": 1311280970,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"gender": "female",
"birthdate": "0000-10-31",
"email": "janedoe@example.com",
"picture": "http://example.com/janedoe/me.jpg"
}

You'll notice that there's a fair amount of information about the user in encoded in that token, but there are a couple of things I want to call out. First, everything from "name" onwards in that example isn't covered by the registered claim names from the spec. They're custom values Auth0 has added based on the scopes you're asking for. Second, the registered claims have special meanings which you'll want to take account of.

  • iss is the issuer field, this should be the IdP.
  • sub is the user's unique identifier, this will usually be an auto-incrementing id, or a UUID, or another unique id.
  • aud is the intended "audience" of the token, this is usually the client id of the app that requested the token.
  • exp and iat are the expiration time and issued at time, respectively in unixtimestamp format.

You can use any of the information in the token inside your application for displaying to the user or making local assertions but it is critically important that you validate the claims in the token have not been tampered with. Especially ensure that the iss and the aud claims are what you expect them to be and that the exp claim is in the future. It's not a huge deal if you're using ID tokens just for user data, but if you use them in lieu of access tokens (you shouldn't, more in a second), token forgery becomes a big problem.

For more information on validating ID tokens vs Access Tokens, please have a read of this excellent Auth0 Article

But is it really bad to use an ID token as an access token?

Permalink to “But is it really bad to use an ID token as an access token?”

Right. Okay. So there are, let's say, different opinions on this subject. Some folks, like Google think it's alright to use the claims in an ID token to establish the user's identity and use that as a proof of Authorization. If you have a highly coupled frontend and backend, this can be "okay" in that your aud claim should be something you generally expect from the backend.

Auth0 agrees with this statement in the article I linked, stating:

As said above, an ID token proves that a user has been authenticated. In a first-party scenario, i.e., in a scenario where the client and the API are both controlled by you, you may decide that your ID token is good to make authorization decisions: maybe all you need to know is the user identity.

If that's enough for you, so be it! But please for the love of the gods validate that the token isn't forged or expired. You still want to have a list of allowed aud claims as well, don't just allow any aud to pass.

😈 So there's also a cool thing called Demonstration of Proof-of-Possession (DPoP) which uses light cryptography to bind a JWT access token to a particular device. It exists so that if an access token leaks it's useless to an attacker since they don't have a private key needed to sign requests.

Relatedly, ID tokens lack scopes, so you won't be able to rely on them for granular authorization from the IdP. Meaning your backend will need to make authorization decisions some other way. You could configure the IdP to pass those as custom claims in the ID token, but at that point just use the access token, that's what it's for.

You're probably getting sick of hearing about tokens, but there are a few more things I should point out if you're digging into the world of OAuth/OIDC and want to avoid some footguns.

  1. Access tokens should be short-lived. This limits the damage should one be stolen. Auth0 defaults the lifetime to 24 hours. I'd recommend far less than that unless your application needs longer access token lifetimes.
    1. JWTs Cannot be revoked, by the way, so if a long-lived JWT access token leaks... you've got a problem on your hands.
  2. If you issue refresh tokens (via the offline_access scope), enable refresh token rotation. This way you can't reuse old refresh tokens.
  3. Limit the lifetime of the refresh token. This can be much longer than the access token, but it should be directly related to how long you want a user to be logged into your application. 7 days, 30 days, or the like are good ideas.
  4. Always validate your JWTs fully. There are libraries for this.
  5. Don't store sensitive data in ID Tokens. They're trivially decodable.
  6. Do not attempt to store client_secrets in a mobile app or a SPA. They will leak.

Right, so I've explained at length the how and the why of OAuth/OIDC, but none of that information actually solves for the "I want to silently log you in to multiple applications". You still need some sort of initiator for this to occur.

This is where OIDC silent login comes into play. You see, most IdPs support this protocol which allows you to skip the login prompt on your IdP if the user has logged in recently. How long between credential prompts is generally configurable, but usually I've seen it set to "the browser session". The way you do this is passing prompt=none when doing the initial redirect to the IdP. If the user's logged in already, the application just redirects them straight back to your site and you proceed with the token exchange as usual.

If they're not, then they're shown the authentication prompt as normal.

SSO Achieved! ... well, not quite. The user, in this scenario, still has to click "login". This is usually okay, since the process winds up being "click login, suddenly the app knows who I am", but sometimes that's unacceptable.

From that point, you have a couple of options:

  1. Combine this technique with the shared cookie thing from above where the cookie stores a trigger to auto-activate the login flow
  2. Redirect around your sites in a loop, hitting the authorization endpoint on each of them
    1. That can get unwieldy when you've got a lot of sites.
  3. Do what StackExchange did with LocalStorage on a shared domain.
  4. Cry, just a little. It's cathartic.

Relatedly, this is also a problem with logout.

Everything that applies to SSO also applies to SLO and the techniques also apply there. OIDC provides a mechanism for a redirect after hitting the logout endpoint, and you can use that to redirect them around and immediately end the session on all sites.

One other thing you can do is have a short-lived access token and revoke the refresh tokens on user logout. That'll ensure that when the access token expires they'll be signed out. So...yeah!

Likewise, if you're using that centralized cookie or local storage approach, you can just destroy those storage bits and force a logout on that end.

Well, I failed at making this post shorter than part 2. We'll try again in Part 4: Passkeys! Lots and lots of information about Passkeys.

So. Many. Passkeys.

Comments