In a typical web application, you want to find the right balance between security and good user experience when it comes to login sessions. Your users will be frustrated if they have to log into the application every day or even multiple times a day (I’m thinking of you Dribbble 😖). Some use cases call for stricter security, e.g. banking. But often times, if you think about applications such as Gmail, Asana or Slack - you might have noticed that you do not need to login very often.
One approach I find very useful to bringing balance in security vs good user experience is dynamic session length. If the user is actively using application every day or every few days, continue extending the session up to a certain period, such as 30 or 60 days. If the user is not actively using the application, expire the session sooner, e.g. after 5 days of no activity.
How can we achieve this in Feathers where stateless jwt tokens are typically used as the access token (as opposed to cookie backed server side persisted sessions)?
One way to achieve this is by storing extra information in the jwt token generated by Feathers. Here’s the algorithm:
- Add
oat
– original issuing timestamp to the jwt token. Unlikeiat
- issued at timestamp, we will use this field to track when this particular chain of jwt tokens have been originally issued. - Every time the token is being verified, check if
oat
is within max desired session length, e.g. has this token (or rather the “ancestor” token) originally been issued within the last 60 days. - If it’s been less than 60 days – regenerate the jwt token, thus extending it’s life by another few hours/days, but keep the
oat
value the same. - If it’s been more than 60 days – do not regenerate the token and let it expire naturally.
Implementation
We’ll start by extending the jwt token payload with the oat
field to denote the original issuing timestamp:
const { AuthenticationService } = require('@feathersjs/authentication')
module.exports = class CustomAuthenticationService extends AuthenticationService {
async getPayload(authResult) {
const { authentication } = authResult
// oat – the original issuing timestamp
// reuse oat if we're authenticating with an existing jwt
// generate a new timestamp if we're creating a brand new jwt
let oat
// authentication.payload is the payload of succesfully decoded existing jwt token
if (authentication.strategy === 'jwt' && authentication.payload && authentication.payload.oat) {
oat = authentication.payload.oat
} else {
oat = Math.round(Date.now() / 1000)
}
return { ...super.payload(), oat }
}
}
Next, we will customise the jwt strategy to use the oat
field to determine if the token should be regenerated, thus extending the session:
const ms = require('ms')
const { startOfDay, endOfDay, isWithinInterval } = require('date-fns')
const { JWTStrategy } = require('@feathersjs/authentication')
module.exports = class CustomJWTStrategy extends JWTStrategy {
async authenticate(authentication, params) {
const config = this.authentication.configuration
const { maximumSessionLength, jwtOptions } = config
// run all of the original authentication logic, e.g. checking
// if the token is there, is valid, is not expired, etc.
const res = await super.authenticate(authentication, params)
// use the oat date to check if we should regenerate the token
const now = Date.now()
const iat = res.authentication.payload.iat * 1000
const oat = res.authentication.payload.oat * 1000
const start = startOfDay(now)
const end = endOfDay(now)
// regenerate only once per day to avoid "waste"
// check if this token has been issued today, which
// would mean we do not need to bother regenerating it
if (!isWithinInterval(iat, { start, end })) {
// now check if by regenerating the token, we will
// not exceed our maximum desired session length
if (oat + ms(maximumSessionLength) > now + ms(jwtOptions.expiresIn)) {
// and now the key trick - by deleting the accessToken here
// we will get Feathers AuthenticationStrategy.create()
// to generate us a new token, but with the original oat
// field as specified in out custom getPayload!
delete res.accessToken
}
}
return res
}
}
To understand why Feathers recreates the token after a succesful authentication, see the AuthenticationStrategy code. The main use case for this logic is for when signing in with a non jwt strategy, e.g. local or oauth strategies. This is where the jwt token gets created. With JWT strategy, the jwt token is already present and is typically reused. But we can use this to our advantage to regenerate fresh tokens for achieving the dynamic session length functionality.
Next, we need to modify the authentication configuration to specify the desired min and max session lengths. We keep the jwtOptions.expiresIn
short, in this example it’s 5 days, which means when the user has been inactive for more than 5 days the token will have expired. And the new maximumSessionLength
setting controls how long we will allow to renew the token until we ask the user to login again. In this case it’s set to 60 days, which means if the user keeps active at least once every ~5 days, they will stay logged in for up to 60 days at which point the token will no longer be renewed, will expire and the user will be asked to login.
authentication:
maximumSessionLength: 60d
jwtOptions:
expiresIn: 5d
Finally, wire up the customised authentication classes in your auth.js
as usual:
const { LocalStrategy } = require('@feathersjs/authentication-local')
const CustomAuthenticationService = require('./CustomAuthenticationService')
const CustomJWTStrategy = require('./CustomJWTStrategy')
module.exports = function auth(app) {
const authentication = new CustomAuthenticationService(app)
authentication.register('local', new LocalStrategy())
authentication.register('jwt', new CustomJWTStrategy())
app.use('/api/authentication', authentication)
}
Closing thoughts
As with everything security related, consider implementing further precautions when making sessions longer.
For example, if the user explicitly logs out of the application, do not accept any of the old access tokens. You can achieve this by storing the loggedOutAt
timestamp in the AuthenticationService.remove()
method and then checking whether the timestamp was issued before or after the loggedOutAt
timestamp in the JWTStrategy.authenticate()
method.
I hope this was useful and will help improve your application. If you have any feedback, especially on what could be improved on the security front, you can reach me on Twitter or Feather’s Slack.