As described in my previous post in this series, I was searching for a sign-in solution with the following attributes:
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.
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 statefull, 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 use-case. 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:
proof of authentication because 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_token to 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 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
resource owner.
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:
read_movies and watch_movies.read_movies is issued.read_movies and watch_movies again).read_movies and watch_movies will be issued, as watch_movies was 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 forgetful, 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 escalation 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 BearerTokenResponse, RefreshTokenGrant, AccessTokenEntityInterface and 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 contact@christiaangoossens.nl with your suggestions.
If you are implementing this as well, keep the following in mind: