Frontend
Credentials Storage - Cookies
Rhino Client's authentication system is based on httponly cookies, issued by the server, and therefore not accessible via javascript. There's no need to store nor to clean access tokens, as they are stored in the auth cookie and completely handled by the browser. The server is the only responsible for issuing cookies as well as cleaning them. If the server fails to clean the auth cookie, the client can enter an unwanted state where it can't clean the cookie, so all requests will use an invalid cookie until it expires.
AuthContext
Uses the React Context API to expose a Provider
holding the authentication state and exposing methods like logIn
and logOut
, that directly alter the auth state.
AuthProvider
issues a request for the /validate_session
, managed by the react-query library. react-query
refetches this request from time to time, so we can be sure that the user is always logged in. If the request fails, it means the user is not logged in, so the exposed user
property becomes null
. On the other hand, if the request is successful, AuthProvider
exposes user
as being the result of the query, meaning it holds whatever the server sends, i.e. the user information.
When a sign in occurs, the entity that issued the sign in request can just call the logIn
from AuthProvider
(passing user information) and signal that a sign in has successfully occurred. logOut
is used in the same way, but for the sign out process.
When the app starts, AuthContext
doesn't know if the user has a valid session or not, so it's neither in the authenticated
state nor in the unauthenticated
. For this case, the initializing
property is exposed, being true when the first /validate_session
request is still in progress. This property is particularly important to allow showing a splash screen or other UI hints.
Exposed properties:
user
: contains either the user information ornull
. If it'snull
, it means it's in theunauthenticated
state, otherwise, it's in theauthenticated
state.initializing
: when the app loads, it's neither authenticated nor unauthenticated, so it gets set totrue
. Once the validate session query is resolved for the first time, it statysfalse
forever.resolving
: any time the validate session query is in progress, it is set totrue
.logOut
: function that setsuser
tonull
, reaching theunauthenticated
state.logIn
: function that receives an object and setsuser
to object received, reaching theauthenticated
state.refreshSession
: function that forces the validate session query to be refetched.
AuthenticatedRoute
It's a HOC that serves as a wrapper for routes that require the user to be authenticated. It is normally used once, in a high position of components tree, wrapping all routes that need authentication. It listens to AuthContext
through useAuth
hook and decides what to render based on the authentication state.
- If
AuthContext
is still resolving, i.e. the app has just loaded and it's still figuring out if it has a valid session or not, it renders<SplashScreen>
- If the user is authenticated, it renders its children
- If the user is not authenticated, it redirects to the login page
NonAuthenticatedRoute
It's similar to AuthenticatedRoute
, but acts the other way around. It's normally used to wrap pre-authentication routes that can't be accessed if the user is authenticated, e.g. sign in, sign up, reset password, etc.
- If
AuthContext
is still resolving, i.e. the app has just loaded and it's still figuring out if it has a valid session or not, it renders<SplashScreen>
- If the user is authenticated, it redirects to the root page, normally
/
- If the user is not authenticated, it renders its children
ValidateSession query
Issues a GET /validate_session
request that returns the user information if the access token stored in the cookie is still valid or a 401 - Unauthorized
in case the user cookie is expired, invalid or absent. The behavior of constantly refetching and re-evaluating if the user is still logged in is delegated to react-query
, so there's never a direct call to react-query
's refetch
function.
Whenever the state of the validate session query changes, AuthContext
re-renders, taking into account its state and running side effects. For example, if the query's isFetching
is true
, AuthProvider
's resolving
will also be true
. If the state indicates success, user
will be set, therefore, the authenticated
state is reached. If the state indicates failure, user
is set to null, so the unauthenticated
state is reached.
SignIn action
Issues a POST /auth/sign_in
request passing user's credentials. Upon success, it calls logIn
from AuthContext
passing the received user information, which in turn assigns this new information to user
, reaching the authenticated
state. Mind that the validate session query is not re-issued, as the user information is already available, cookie is automatically set and there's no doubt the user is logged in. Upon failure, it does nothing, as it should not change the authenticate state.
SignOut action
Issues a DELETE /auth/sign_out
request. Once it returns, either a success or a failure, react-query
's removeQueries
is called, in order to cancel all requests and avoid problems with requests being issued without a proper cookie and leading to unexpected problems.
If it is a success, it simply calls logOut
from AuthContext
, which sets user
to null
, reaching the unauthenticated state. The server is responsible for issuing a cookie clean up command, so the client relies on that and on the browser actually deleting the token.
In case of a failure, it analyzes if the error is a NetworkUnauthorizedError
instance, meaning that the user's session is not valid anymore. If it is, the sign out action simply calls logOut
from AuthContext
, which sets user
to null
, reaching the unauthenticated state, just like in the case of a success. A NetworkUnauthorizedError
can happen form may reasons: token was deleted on the database, cookie expired, token expired, malformed cookie, etc. For the specific case in which the token was deleted from the database, DeviseTokenAuth
returns a 404 - NotFound
, as it indeed wasn't possible to find the corresponding user. For this case, networking.js
has a special treatment, which makes sure an NetworkUnauthorizedError
is raised, so the response is treated correctly.
Overall flow for bootstrapping the app
Use cases
User does not have a cookie set and navigates to the /sign_in
page
- When the app loads,
AuthProvider
starts AuthProvider
issues a validate token query- The first state emitted by
AuthProvider
hasinitializing: true
SignInPage
is wrapped under aNonAuthenticatedRoute
NonAuthenticatedRoute
checks thatAuthProvider
hasinitializing === true
and renders<SplashScreen />
- Validate token query returns
401 - Unauthorized
AuthProvider
setsinitializing: false
anduser: null
NonAuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: null
, so it renders<SignInPage />
User already has a cookie set and navigates to the /sign_in
page
- When the app loads,
AuthProvider
starts AuthProvider
issues a validate token query- The first state emitted by
AuthProvider
hasinitializing: true
SignInPage
is wrapped under aNonAuthenticatedRoute
NonAuthenticatedRoute
checks thatAuthProvider
hasinitializing === true
and renders<SplashScreen />
- Validate token query returns
200 - Ok
with user's data AuthProvider
setsinitializing: false
anduser: {...}
NonAuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: {...}
, so it redirects toroutePaths.rootpath()
User does not have a cookie set and navigates to the /
root page
- When the app loads,
AuthProvider
starts AuthProvider
issues a validate token query- The first state emitted by
AuthProvider
hasinitializing: true
- The root page, probably
/
returned byroutePaths.rootpath()
is wrapped under aAuthenticatedRoute
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === true
and renders<SplashScreen />
- Validate token query returns
401 - Unauthorized
AuthProvider
setsinitializing: false
anduser: null
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: null
, so it redirects to/sign_in
User has a cookie set and navigates to the /
root page
- When the app loads,
AuthProvider
starts AuthProvider
issues a validate token query- The first state emitted by
AuthProvider
hasinitializing: true
- The root page, probably
/
returned byroutePaths.rootpath()
is wrapped under aAuthenticatedRoute
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === true
and renders<SplashScreen />
- Validate token query returns
200 - Ok
with user's data AuthProvider
setsinitializing: false
anduser: {...}
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: {...}
, so it renders the page
User is not logged in yet and issues a successful sign in request from the sign in page
- Current state from
AuthProvider
hasuser: null
andinitializing: false
NonAuthenticatedRoute
is rendering its children becauseuser: null
andinitializing: false
- User inputs valids credentials and clicks on the sign in button
<SignInPage />
callsmutate()
from theuseSignInAction
hookPOST /api/auth/sign_in
is issued- Server responds with
201 - Created
with user's data - Browser sets cookie
<SignInPage />
callslogIn(userData)
from theuseAuth
hookAuthProvider
setsuser: {...}
NonAuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: {...}
, so it redirects toroutePaths.rootpath()
- User has logged in
User is logged in with a valid cookie and issues a sign out request through the AccountMenu component
- Current state from
AuthProvider
hasuser: {...}
andinitializing: false
AuthenticatedRoute
is rendering its children becauseuser: {...}
andinitializing: false
- User clicks th sign out button from the
<AccountMenu />
component <AccountMenu />
callsmutate()
from theuseSignOutAction
hookDELETE /api/auth/sign_out
is issued- If the server responds with
200 - Ok
- Browser clears cookie
onSettled
callback fromreact-query
is run and callsreact-query
'squeryClient.removeQueries
, cancelling any ongoing requests so unexpected behavior because of invalid auth cookie is avoidedonSuccess
callback fromreact-query
is run and callslogOut()
from theuseAuth
hookAuthProvider
setsuser: null
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: null
, so it redirects to/sign_in
- User has logged out
- If the server responds with
401 - Unauthorized
- Browser clears cookie
networking.js
sees it is a 401 and throws aNetworkUnauthorizedError
onSettled
callback fromreact-query
is run and callsreact-query
'squeryClient.removeQueries
, cancelling any ongoing requests so unexpected behavior because of invalid auth cookie is avoidedonFailure
callback fromreact-query
is run, checks that the error is aNetworkUnauthorizedError
, and callslogOut()
from theuseAuth
hookAuthProvider
setsuser: null
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: null
, so it redirects to/sign_in
- User has logged out
- If the server responds with
404 - NotFound
- Browser clears cookie
networking.js
sees it is a 404 for the sign out request and throws aNetworkUnauthorizedError
onSettled
callback fromreact-query
is run and callsreact-query
'squeryClient.removeQueries
, cancelling any ongoing requests so unexpected behavior because of invalid auth cookie is avoidedonFailure
callback fromreact-query
is run, checks that the error is aNetworkUnauthorizedError
, and callslogOut()
from theuseAuth
hookAuthProvider
setsuser: null
AuthenticatedRoute
checks thatAuthProvider
hasinitializing === false
anduser: null
, so it redirects to/sign_in
- User has logged out
- If the server responds with another type of error
- Browser clears cookie
networking.js
sees it neither a 401 nor a 404 and throws other types of errorsonSettled
callback fromreact-query
is run and callsreact-query
'squeryClient.removeQueries
, cancelling any ongoing requests so unexpected behavior because of invalid auth cookie is avoidedonFailure
callback fromreact-query
is run, checks that the error is not aNetworkUnauthorizedError
, and does not calllogOut()
from theuseAuth
hook- Use has not logged out