As detailed in my previous post, we are using Ktor as a framework for creating the Web API portion of Mineservers. As we were working to add sessions to the API (and for the accompanying UI), we had several decision points and a few challenges.

Session storage

First, we have to choose how to store sessions. We can use cookies or headers and we can store the session information client or server side. Cookie based sessions are pretty typical and when first prototyping they seemed easier as we were just a simple HTML website talking to a backend. Considering our intention is to provide API access, long-term it likely makes more sense to use headers for storing session info.

In Ktor, we simply install the sessions feature:

install(Sessions) {
    header<WebSession>("MINESERVERS_WEB_SESSION")
}

Notice we’re giving our header configuration a type of WebSession. What we store in the session isn’t super relevant but it’s worth noting that we are storing the session details client-side. If we wanted to keep the session information on the server (and the client only gets a reference), we’d need a storage backend on the service which has architectural implications as we’d either need remote session storage (eg redis) or the server cannot be stateless (cache/file storage). Our intention is to deploy as a stateless service in the cloud, so we ended up storing the session contents with the client. This exposes us to potential replay attacks where a captured session can be used by an attacker at a later time. To mitigate this risk, we want to make sure the session is encrypted by our own secure (and rotatable) secret.

Now our session config looks like this:

install(Sessions) {
    header<WebSession>("MINESERVERS_WEB_SESSION") {
        transform(
            SessionTransportTransformerEncrypt(encryptKey, authKey)
        )
    }
}

This will use AES to encrypt and HmacSHA256 to authenticate it by default. Be sure to check out the Ktor Sessions documentation for more.

Session Expiration

Second, we need to be able to expire stale sessions. The Ktor documentation helps us a bit:

When a user logs out, or a session should be cleared for any other reason, you can call the clear function: call.sessions.clear<MySession>()

That lets us clear sessions when a user logs out, but we still need to be able to set an expiration so they can’t live forever. We can once again find guidance on the docs which involves including an expiration time in the session payload and checking if it’s expired when making a call, in this case via an extension function.

We can take that a step further as needing to remember to check the session for expiration on any authenticated call (even using an extension) is sub-optimal. Thankfully, we can create and install our own custom feature. This means we can “intercept” calls and clear any expired sessions before our API layer interacts with it. I’ve also added separate configurations for idle timeout and max session duration, so a user can be signed out due to inactivity in addition to having a maximum session length.

The core of the feature is intercepting the call:

private fun intercept(
    context: PipelineContext<Unit, ApplicationCall>
) {
    val session = context.call.sessions.get<WebSession>() ?: return
    val now = Instant.now().epochSecond
    if (
        now - session.createdAt > maxDuration ||
        now - session.lastUse > idleTimeout
    ) {
        context.call.sessions.clear<WebSession>()
    } else {
        context.call.sessions.set(session.copy(lastUse = now))
    }
}

Add in the configuration and wrapping it in the Feature API and we get:

/**
 * Automatically expires WebSessions after the maxDuration or after idle (no activity) for idleTimeout
 */
@ExperimentalTime
class ExpiringWebSessions(configuration: Configuration) {

    class Configuration {
        var idleTimeout: Duration = 3600.seconds
        var maxDuration: Duration = 43200.seconds
    }

    private val idleTimeout = configuration.idleTimeout.inSeconds
    private val maxDuration = configuration.maxDuration.inSeconds

    private fun intercept(context: PipelineContext<Unit, ApplicationCall>) {
        val session = context.call.sessions.get<WebSession>() ?: return
        val now = Instant.now().epochSecond
        if (now - session.createdAt > maxDuration || now - session.lastUse > idleTimeout) {
            context.call.sessions.clear<WebSession>()
        } else {
            context.call.sessions.set(session.copy(lastUse = now))
        }
    }

    companion object Feature : ApplicationFeature<ApplicationCallPipeline, Configuration, ExpiringWebSessions> {
        override val key = AttributeKey<ExpiringWebSessions>("ExpiringWebSessions")

        override fun install(
            pipeline: ApplicationCallPipeline,
            configure: Configuration.() -> Unit
        ): ExpiringWebSessions {
            val configuration = Configuration().apply(configure)
            val feature = ExpiringWebSessions(configuration)

            pipeline.intercept(ApplicationCallPipeline.Call) { feature.intercept(this) }

            return feature
        }
    }
}

Here’s our WebSession class:

data class WebSession(
    val id: UUID,
    val displayName: String,
    val avatarUrl: String?,
    val createdAt: Long,
    val lastUse: Long
)

The important part is that we have our unique identifier and details about when the session was created and last used.

Now, we can install the feature like any other:

install(ExpiringWebSessions) {
    idleTimeout = idleTimeout
    maxDuration = maxDuration
}

These aren’t unique challenges but I think it’s helpful to review the options, as well as share our first time solving them in Ktor.