As described in my previous post in this series: https://chrg.nl/2018/07/25/modern-first-party-auth/, I was searching for a sign-in solution with the following attributes:
- Stateless: I want my authentication and authorization service to handle everything from identity to ACLs and permissions itself. A resource server should only have to check the validity of the token and then read the permissions out of the token. No back-checking requests necesssary.
- Preventing code duplication: I want only one place to change the authentication methods: therefore a change in an upstream auth provider (social login) should not affect my clients written to authenticate to my auth server.
- Working on both the web and in mobile clients: I had to come up with a solution that worked on both the web, and inside a mobile client.
- Working with external auth providers, instead of just passwords: It had to work well with external providers in extension to username & password. Requirements could change at any time, and thus I didn't want to lock a certain client to a certain method. Enabling methods for some clients should be as easy as flipping a switch.
Again, as described in the previous post, OAuth2 (with some authentication extensions not described here) would work well. Because all clients will either use the Authorization Code or Implicit Grant, changing upstream auth would be as easy as just changing the /authorize endpoint: no client change necessary.
OAuth2 also works well on both the web, as with native clients (using PCKE and for instance Chrome Browser Tabs instead of Webview). It has also been tested there, and is used in practice for third-party apps all the time.
So, what's the problem?
Well, OAuth2 was designed for third-parties. It's often implemented as an additional method for coupling your API to external apps, besides the normal session and cookie based login method for your own apps. In most implementations I found, the internal apps are also still stateful, requesting user permissions with every request, based on the session, instead of using a stateless method like JWTs.
As far as I can see, using OAuth2 for first-party apps as well (besides the Resource Owner Credentials Grant) is not very popular, and thus server libraries are not well adapted to this usecase. I encountered some issues, which I will describe below:
I was using thephpleague/oauth2-server to implement my OAuth2 server. It's a great library and you should give it a try if you are using PHP! It allows great freedom in implementing how you want to handle both tokens and scopes, but it's still secure by default.
For my use-case I needed to change the following things from the default:
- Permissions and Scopes: As I am using very short-lived access tokens (for instance 10 min exp time) and embedding granted user permissions into the token (to enable the true stateless system described above), I had to change the way scopes were issued.
- Adding details about the authentication to the token: Because I use the token as well for authentication purposes (it's specifically used as a
proof of authenticationbecause it's what you get as an exchange for your password), OAuth2 wasn't sufficient. Therefore I needed to add some attributes from the OpenID Connect
id_tokento my token, specifically some basic user details (for UI) and details about the authentication method, time and security level. My apps could then decide if the authentication was recent enough to assume that the user was present (like OpenID Connect does).
- Changing the Auth Code and Refresh Token to convey information between sessions: To keep track of the permissions granted by the user or about the original authentication event, this information should be passed through those encrypted tokens as well.
Permissions and scopes
Changing the expiration time on the token was very easy with this library, and changing the ScopeRepository to issue the correct scopes depending on the requested scopes and rights for that user in the database was also a piece of cake (except for some small issue where the method wasn't called in the right place as the docs were suggesting: PR).
However, soon some differences between my interpretation of the OAuth2 spec and theirs began to appear. The OAuth2 spec says:
The requested scope MUST NOT include any scope
not originally granted by the resource owner, and if omitted is
treated as equal to the scope originally granted by the
Therefore, logically, when refreshing a token with a refresh_token, you cannot obtain more rights than you started with. I interpreted this as:
The literal string of requested scopes is stored (in my case into the encrypted refresh token) at the first authorize request, and every time this string is compared again to the current permissions in the database.
They interpreted as:
After the issuance of a token, the granted scopes are stored into the refresh token, and upon refresh with that token, we'll check the database again if all of those scopes can still be granted. One can thus never obtain more scopes than were in the last token.
Although both interpretations are fine according to the standard, as we both only issue scopes approved by the user, my case also allows the user to approve of scopes that are currently unavailable, and can thus 'reappear' in a refresh token after not being present in the first access token, while in theirs this is not possible.
An example will make this clear:
- The client requests the following scopes for the user in this fake app:
- If the client is a first-party client and the client has authenticated (with a client_secret) the user will automatically approve of those scopes (Administrator pre-consent), if we're unsure of this client, the user will be prompted for consent in normal OAuth fashion. The final set of approved scopes is stored on the server.
- Because the user is not a paid user yet, they can only read the list of movies, not yet watch them. Therefore an access_token with
- During this session the user becomes a paid user. Therefore the client will refresh the token to obtain the new scopes (and it will thus request
- In my interpretation, a new token with
watch_movieswill be issued, as
watch_movieswas approved, just not granted. In their interpretation the client is required to logout the user, and have the user sign-in again to obtain the new scope.
For third-parties their interpretation makes sense, because this problem doesn't really matter. Users are forgetfull, and may forget that they have even approved that, and logging in again with the external party may not be as much of a hassle.
However, when using this for a first-party app, it's pretty strange that the user has to login again to get new permissions. Approval is done without user involvement (just like it is for administrator approved third party apps in normal applications of OAuth2), so for a user this is just weird UX.
Both interpretations adhere to the standard, and they should be equally safe. In both systems if the client requests the
delete_movies scope in the refresh request, after it wasn't in the original request (see step 1), it will NOT be issued, even if the user is capable of this. Therefore, privilege escallation is not possible. It's also possible to restrict clients in their scopes with both systems: if a client cannot request some scopes, they can always be removed in step 1, before comparing the scopes with the database permissions for that user. In this way, some clients may never request certain scopes, even if the user is capable of those things.
This issue resulted in an issue on the thephpleague/oauth2-server library, because I wanted some input on how to implement this. @simonhamp and @Sephster helpfully provided me with some input (although my explanation of my situation may have lead them to believe that my resource server would have any way of knowing when data was unavailable and I may not have stressed enough that I want to be stateless and thus prevent resource server to auth server communication about user permissions).
In my use-case some data may be temporarily unavailable to users. This may be at any point, for any duration of time, and could best be viewed as 'retracting a users right to view the data for a certain amount of time'. Who can view what is determined by the auth server, as the resource server has no way of knowing this (it's not like the data is always unavailable at 9.00 pm for some usergroup, it may be at any moment, for any group).
I thus had to include these rights somewhere into the token. I did this through the scopes, but this didn't appear to be very common:
The most solid reason for this is because I believe the majority of folks use scopes in the way they were intended (this is the first mention I've seen of scopes being used in this way): If you can request them and the resource owner granted them, then it's generally assumed that the token you use has them and the refresh token can have them too.
I can fully understand this stance from the way OAuth2 was intended: a third-party would not expect scopes to be used as permissions, as scopes for third-parties just indicate that a user falls into a certain role.
For first-party apps however, this is the easiest stateless way of decoupling permissions from the resource servers. This was my first introduction to the idea of OAuth2 being used for first-parties was very weird.
Another issue I encountered when adapting OAuth2 to first-party applications, was the fact that OAuth2 isn't suited for authentication. It lacks details about the user and authentication event.
To add these, I also needed to add that information to the Auth Code, to persist it between sessions. This led to another PR.
After some local edits to the
AuthCodeGrant, I had finally implemented everything necessary for first-party OAuth, as a user would expect it to work.
This may not be the best way of doing this, although I tried to minimize edits to OAuth, to keep it as secure as possible. If you have any input on this, just send an email to firstname.lastname@example.org with your suggestions.
If you are implementing this as well, keep the following in mind:
- Your access tokens should be very shortlived, as there is no way to retract permissions between expirations. This may be fine in your usecase, or it may not be acceptible.
- Your auth code and refresh tokens should be opague and encrypted, because if the information can be edited, user consent can be faked.
- The user should always consent specifically to all permissions, or an administrator should approve of the app (with first-party apps) with sufficient client checks applied. The Authorization Code Flow is strongly recommended.