TLDR; just see the Recommendations section below.
On the Platform Team call this week, @ruhanga pointed out that the REST API returns two different error codes on authentication failure, a 401 if the authentication failed and a 403 if the session was expired. In both these cases, the client needs to reauthenticate with the server. This behaviour was explicitly added to the REST module due to REST-277, which was looking for a way to distinguish between a 401 for “authentication failed” and a 401 for “session expired”.
A distinction between HTTP response codes has existed since at least HTTP 1.0 (RFC 1945), but 401 responses were used in a particular flow for HTTP BASIC authentication. Essentially, the browser would send a request to the server and if the server decided that a resource need authentication it would respond with an HTTP 401 response and an appropriate challenge in the
WWW-Authenticate header. The browser would then collect credentials from the client and respond to the challenge. If the server decided the credentials were wrong or the credentials provided did not grant the user access to the resource, it would respond with a second HTTP 401 response, which effectively killed the login. (Basically, this second response is the “bad username or password” response).
HTTP 403 responses existed completely outside of the above described flow. I.e., a 403 (Forbidden) response “indicates that the server understood the request but refuses to authorize it” (from RFC-7231).
In addition to considering how the HTTP specs themselves have involved it’s useful to consider how APIs in general actually work.
OAuth 2.0 is a specification designed to create a secure way for browser-based applications to communicate authorization information to an API server. OAuth 2.0 makes use of the following HTTP codes when dealing with authorization failures (taken from RFC 6750):
HTTP 400: if the authorization request is malformed. Among other things, this would include where the token was request (via the 401 flow above), but the token wasn’t sent. HTTP 401: The access token provided is expired, revoked, malformed, or invalid for other reasons. HTTP 403: The request requires higher privileges than provided by the access token.
The book REST API Design Rulebook from O’Reilly gives these simple rules:
- “401 (“Unauthorized”) must be used when there is a problem with the client’s credentials”
- “403 (“Forbidden”) should be used to forbid access regardless of authorization state”
Currently, most OpenMRS API clients seem to rely on the availability of cookies to authenticate with the REST API; the exception I can find are the react-components library, which appears to use pre-emptive Basic authentication.
Conclusions and Recommendations
It seems that the behaviour requested in REST-277 results in us using an HTTP status code in a very non-standard way. In particular, I cannot find another instance where a 403 status code is used to mean “retry authentication”; the 401 status code is exclusively used for this and 403 is used exclusively for cases where the auth flow is terminated due to an authorization (rather than authentication) error. Thus, we should revert to returning a 401 status code regardless of whether the action failed due to an expired cookie or invalid authentication credentials.
The request in REST-277, however, seems reasonable. An API client may need to distinguish between “the server rejected this request, but you can try to authenticate again” and “the server rejected this request” and the various RFCs provide us with a mechanism to do that via including or not including an
WWW-Authenticate header in the response. Since we support two authentication schemes, cookie-based authentication (which seems to be widely used) and Basic authentication we should use this header to indicate this.
I would suggest we adopt the following flow:
- Request hits the API, we check whether there is a current valid session (Cookie-based authentication). If there is, the request will be processed.
- If there isn’t a currently valid session, we check for a Basic authentication header.
- If there isn’t a Basic authentication header, we return an HTTP 401 error with the
WWW-Authenticateheader set to
- If there is a Basic authentication header, we attempt to login with those credentials. If the login succeeds, the request is processsed as normal.
- If the login fails at this point we return an HTTP 401 error with the
WWW-Authenticateheader set to
This flow should allow:
- The use of cookie-based authentication where a valid session already exists
- The use of pre-emptive Basic authentication
- The use of the standard Basic authentication flow (where a request is made and a 401 response with the
WWW-Authenticateheader is returned, followed by a request with Basic authentication).
- The client to surmise that the cookie they are sending is invalid.
I also don’t think this unnecessarily reveals any information that a hacker would not otherwise be able to infer. The downside is that a mis-behaved API client might not read the
WWW-Authenticate header and take it’s presence to mean “continue attempting to authenticate”, but that’s probably outside the scope of this.
Thoughts? Comments? Corrections? Amendations?