DEV Community

Cover image for GraphQL backend — token expiry
Mahendran
Mahendran

Posted on • Originally published at mahendranv.github.io

GraphQL backend — token expiry

In the previous post we covered authentication and role management. However, it assumed any invalid session to be a visitor instead of throwing 401—Unauthorized client error. This post covers the token expiry aspect of API call.

Let's start with coding part. Following block diagram explains the interaction between each component. As usual, we'll build the system bottom-up.

🎟 Token Service

To manage session and expire it, we need meta attributes other than just the token. So, let's beef up the token to a data class called SessionToken. This will hold created & last access timestamps in addition to the token itself.


data class SessionToken(
    val token: String,
    val created: OffsetDateTime,
    var lastAccess: OffsetDateTime = created
)

Enter fullscreen mode Exit fullscreen mode

Our token service is a simple repository which abstracts CRUD on token. When user login-register, a new token generated and wrapped in SessionToken. For every web request made, the token updated with last access time. In case user logout or token expired, entry will be deleted.

Below is our simple implementation — we store our mock tokens as usual to keep our focus on expiry. The created time points to now() i.e we emulate like user logged in at the time of launch.


@Service
class TokenService {

    private val store = mutableMapOf<String, SessionToken>()

    init {
        store["token-buyer"] =
            SessionToken("token-buyer", OffsetDateTime.now())
        store["token-seller"] =
            SessionToken("token-seller", OffsetDateTime.now())
    }

    fun createToken() = SessionToken(
        token = UUID.randomUUID().toString(),
        created = OffsetDateTime.now()
    )

    fun peekToken(token: String?): SessionToken? = store[token]

    fun updateLastAccessTime(token: String) {
        // TODO: Token not found in store - fishy!!
        store[token]?.lastAccess = OffsetDateTime.now()
    }

    fun deleteToken(token: String?) {
        store.remove(token)
    }
}

Enter fullscreen mode Exit fullscreen mode

⏲ Token Expiry Strategy

When it comes to expiry, it's always a good practice to abstract out the validation part and create functional interface to carry out the validation. This will help us plug-in different implementations in the system with little config change.

This brings us to a functional interface SessionExpiryStrategy which takes a SessionToken validate and return whether it is expired.


interface SessionExpiryStrategy {

    fun isSessionExpired(token: SessionToken): Boolean

}

Enter fullscreen mode Exit fullscreen mode

An implementation is only good when explained with its implementations. We consider three different strategies for this.

  1. Fixed lifespan : Session will expire after 30 days
  2. Timeout : If user is not active for 3 minutes — logout
  3. Extended Lifespan: If user is active during the 30th day, give him grace period in 3 minute window
@Component("fixed_time")
class FixedLifeSpanStrategy : SessionExpiryStrategy {
    // 30 days fixed lifespan
    val MAX_DURATION = 30 * 24 * 60 * 60
    override fun isSessionExpired(token: SessionToken): Boolean {
        val durationSinceLogin = ChronoUnit.SECONDS.between(
            token.created,
            OffsetDateTime.now()
        )
        return durationSinceLogin > MAX_DURATION
    }
}
Enter fullscreen mode Exit fullscreen mode
@Component("timeout")
class TimeoutExpiryStrategy : SessionExpiryStrategy {
    // Logout after 3 minutes of inactivity
    const val MAX_DURATION = 3 * 60
    override fun isSessionExpired(token: SessionToken): Boolean {
        val durationSinceLastAccess = ChronoUnit.SECONDS.between(
            token.lastAccess,
            OffsetDateTime.now()
        )
        return durationSinceLastAccess > MAX_DURATION
    }
}
Enter fullscreen mode Exit fullscreen mode
@Component("extended")
class ExtendedLifeSpanStrategy : SessionExpiryStrategy {
    private val timeoutExpiryStrategy = TimeoutExpiryStrategy()
    private val fixedLifeSpanStrategy = FixedLifeSpanStrategy()
    override fun isSessionExpired(token: SessionToken): Boolean {
        return if (fixedLifeSpanStrategy.isSessionExpired(token)) {
            // 30 days past, if user is in the mid of something try the timeout
            // to extend his session
            timeoutExpiryStrategy.isSessionExpired(token)
        } else {
            // 30 days not past yet Let him use the system
            false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Few Usecases:
As you can see in ExtendedLifeSpanStrategy, we can mix-match the implementations to tailor user experience. Let's say the buyer in our e-commerce website actively purchasing something and just before he makes payment, session expire due to 30 day lifespan — It would be frustrating. Three minutes added on each api call will alleviate the expiry.

If we want Seller account to logout on 3 minutes inactivity and a fixed timespan for Buyer, it can be achieved by picking strategy 'per role'.


val strategies = mapOf(
    BUYER to fixedTimeSpan,
    SELLER to timeoutStrategy
)

...

strategy[role].isSessionExpired(token)

Enter fullscreen mode Exit fullscreen mode

We have a strategy and a tokenservice in place — let's move on to the final piece where we connect both.

👮 Request Manager

Before we move on to the logic, let's fix the 🐞 in user service. From now, it will return null role for unknown/expired tokens. Still, if there is no token present, session will be identified as VISITOR.


    fun identifyRole(token: String?): Roles? {
        return if (token == null)
            Roles.VISITOR
        else
            role[token]
    }

Enter fullscreen mode Exit fullscreen mode

In the request manager, wire the expiry strategy as you see fit. We'll use qualifiers to inject different strategies that we implemented in last section.


    @Autowired
    @Qualifier("timeout")
//    @Qualifier("extended")
//    @Qualifier("fixed_time")
    private lateinit var tokenExpiryStrategy: SessionExpiryStrategy

     // Save the session info per request. Retrieve it throughout the request
    fun saveSession(request: HttpServletRequest) {
        // Retrieve auth token from request - if any
        val token: String? = request.getHeader(HEADER_TOKEN)

        // Identify role
        val role = userService.identifyRole(token)
        if (role == null) {
            broadcastTokenWipe(token)
            throw unauthorizedException()
        }

        // Handling users that need a session
        if (role != Roles.VISITOR) {
            val sessionToken = tokenService.peekToken(token)
            if (sessionToken == null || tokenExpiryStrategy.isSessionExpired(sessionToken)) {
                broadcastTokenWipe(token)
                throw unauthorizedException()
            }

            // punch-in to the token service
            tokenService.updateLastAccessTime(token!!)
        }

        request.setAttribute(
            KEY_SESSION, DummySession(
                role = role
            )
        )
    }

Enter fullscreen mode Exit fullscreen mode

Added inline comments for clarity, again a walkthrough here. If role is null, throw exception. If session token is null or expired for non-VISITORs throw exception. In both cases wipe the token off the system.

So, what do we throw? — 401 response.

   private fun unauthorizedException() = HttpClientErrorException.create(
        HttpStatus.UNAUTHORIZED,
        "",
        HttpHeaders.EMPTY,
        ByteArray(0),
        Charsets.UTF_8
    )
Enter fullscreen mode Exit fullscreen mode

If the session is valid, update the last access time in token service to support TimeoutStrategy.

🐞 More on error handling

In case, the session is timeout, don't let the request proceed further to protect resources. i.e break the chain in request filter.


@Component
class DummyRequestFilter : OncePerRequestFilter() {

    @Autowired
    private lateinit var requestManager: DummyRequestManager

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            // Feed the request to request manager for session preparation
            requestManager.saveSession(request)

            // Resume with request
            filterChain.doFilter(request, response)
        } catch (e: HttpClientErrorException) {
            response.sendError(e.rawStatusCode, e.statusText)
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

In dummy request filter, once per request check whether the token is valid. If so, resume the chain, otherwise catch and propogate the 401 error code to the client.

🐛 That's it 🐛


🚀 Run it!

curl 'http://localhost:8080/graphql' \
  -H 'x-auth-token: token-random-one' \
  -H 'Content-Type: application/json' \
  --data-raw '{"query":"mutation {addProduct}","variables":null}' \
  --compressed

{"timestamp":"2021-06-01T18:28:55.325+00:00","status":401,"error":"Unauthorized","path":"/graphql"}

curl 'http://localhost:8080/graphql' \
  -H 'x-auth-token: token-seller' \ 
  -H 'Content-Type: application/json' \
  --data-raw '{"query":"mutation {addProduct}","variables":null}' \
  --compressed

{"data":{"addProduct":"dummy-product-id"}}

Enter fullscreen mode Exit fullscreen mode

📖 Resources

  1. Github repo
  2. GraphQL playground — http://localhost:8080/graphiql

Discussion (0)