Implement access and refresh token for Ktor and Android
Thought Process
Some time ago, I implemented and wrote about how to perform user registration and authentication in Ktor here. I didn’t implement the refresh token mechanism because it wasn’t something I thought was important, but as the software evolved and more use cases arose, the need for a refresh authentication token became apparent.
This article will be divided into two sections: Section 1 for the server side and Section 2 for the client side (mobile application).
Server-side Setup
Note: Before we continue, there are many ways to implement access and refresh token, so this is just the method I chose to use, and it worked for me
The server-side setup is just like the setup in the article link I provided above, with just slight changes. How I plan to go about this is to have an access token with a shorter life span than the refresh token; for example, if the access token expires in a week, the refresh token should expire in two or three weeks, With that, we can get a new access token by using the refresh token to request a new one.
Code Changes
/** | |
*JwtController Class | |
* class JwtController(val tokenConfiguration: TokenConfig) { ... }: Defines a controller class for JWT operations, which is initialized with a TokenConfig object containing the configuration for tokens. | |
* | |
* val audience = tokenConfiguration.audience: Extracts the audience value from tokenConfiguration and assigns it to the audience property. The audience typically defines the recipients that the JWT is intended for. | |
* | |
* val issuer = tokenConfiguration.issuer: Similar to audience, this extracts the issuer value from tokenConfiguration and assigns it to the issuer property. The issuer is the entity that issues the token. | |
* | |
* fun generateToken(userId: String, tokenType: String, expirationDate: Long): String { ... }: A method to generate a token with specified userId, tokenType, and expirationDate. It creates a JWT with the specified audience, issuer, and claims (like user ID and token type), and sets an expiration date. It then signs the token with a secret key. | |
* | |
* fun verifyToken(tokenType: String): JWTVerifier { ... }: A method to create a verifier for tokens. It specifies requirements like the algorithm, audience, issuer, and a claim (token_type). It's used to validate tokens. | |
* | |
* generateUserTokens Function | |
* fun generateUserTokens(userId: String, jwt: JwtController,): UserTokensResponse { ... }: A top-level function to generate both access and refresh tokens for a user. | |
* | |
* It calculates expiration dates for both tokens using getExpirationDate and the expiration timestamps from jwt.tokenConfiguration. | |
* | |
* It then generates a refresh token and an access token by calling jwt.generateToken, specifying the user ID, the type of token (access or refresh), and the expiration date in milliseconds. | |
* | |
* Finally, it returns a UserTokensResponse object containing the expiration timestamps, access token, and refresh token. | |
* | |
* getExpirationDate Function | |
* private fun getExpirationDate(timestamp: Long): Date { ... }: A helper function that calculates the expiration date of a token. It adds the provided timestamp (in milliseconds) to the current system time and returns the result as a Date object. | |
* | |
* **/ | |
class JwtController(val tokenConfig: TokenConfig) { | |
val audience = tokenConfig.audience | |
val issuer = tokenConfig.issuer | |
// method to generate token | |
fun generateToken(userId: String, tokenType: String, expirationDate: Long): String { | |
return JWT.create() | |
.withAudience(audience) | |
.withIssuer(issuer) | |
.withClaim("id", userId) | |
.withClaim("token_type", tokenType) | |
.withExpiresAt( | |
Date( | |
System.currentTimeMillis() + expirationDate | |
) | |
) | |
.sign(Algorithm.HMAC256(System.getenv("SECRET"))) | |
} | |
// method to verify token receive | |
fun verifyToken(tokenType: String): JWTVerifier { | |
return JWT.require(Algorithm.HMAC256(secret)) | |
.withAudience(audience) | |
.withIssuer(issuer) | |
.withClaim("token_type", tokenType) | |
.build() | |
} | |
} | |
fun generateUserTokens( | |
userId: String, jwt: JwtController, | |
): TokensResponse { | |
val accessTokenExpirationDate = getExpirationDate(jwt.tokenConfiguration.accessTokenExpirationTimestamp) | |
val refreshTokenExpirationDate = getExpirationDate(jwt.tokenConfiguration.refreshTokenExpirationTimestamp) | |
val refreshToken = jwt.generateToken(userId, TokenType.REFRESH_TOKEN.name, expirationDate = 24L * 60L * 60L * 1000L) | |
val accessToken = jwt.generateToken(userId, TokenType.ACCESS_TOKEN.name, expirationDate = 3L * 60L * 1000L) | |
return TokensResponse( | |
accessTokenExpirationDate.time, refreshTokenExpirationDate.time, accessToken, refreshToken | |
) | |
} | |
private fun getExpirationDate(timestamp: Long): Date { | |
return Date(System.currentTimeMillis() + timestamp) | |
} |
So, you will notice the code changes from the old code, and I added a new method that generates both the access and refresh tokens with the user ID, token type, and hardcoded expiration date (you should not hardcode this).
After completing the earlier steps, we add the security plugin file:
/** | |
* configureSecurity Function | |
* fun Application.configureSecurity(jwtController: JwtController) { ... }: This extension function is defined for the Application class, taking a JwtController instance as an argument. It's responsible for configuring the security aspects of the Ktor application, particularly JWT authentication. | |
* Authentication Configuration | |
* install(Authentication) { ... }: Installs the Authentication feature into the Ktor application. This feature is used to secure your application by authenticating requests. | |
* JWT Authentication for Access Tokens | |
* jwt("main_auth_jwt") { ... }: Configures JWT authentication with the name "main_auth_jwt". This is used for authenticating access tokens. | |
* | |
* verifier(...): Sets up the JWT verifier by using the verifyToken method of the jwtController, specifying that it should verify access tokens. The verifier is responsible for validating the signatures of incoming tokens to ensure they were issued by the server and haven't been tampered with. | |
* | |
* validate { credential -> ... }: Defines the logic to validate the claims within the token. It checks if the token's audience matches the expected audience (jwtController.audience) and if the token contains a "User_id" claim. If the validation passes, a JWTPrincipal is returned, carrying the token's payload. Otherwise, null is returned to indicate invalid credentials. | |
* | |
* unauthorized(): Configures the response to unauthorized requests. This is a custom function added to the JWTAuthenticationProvider.Config class. | |
* | |
* JWT Authentication for Refresh Tokens | |
* jwt("refresh_auth_jwt") { ... }: Similar to the previous block, but this one is configured for handling refresh tokens with the name "refresh_auth_jwt". | |
* | |
* It also sets up a verifier specifically for refresh tokens and uses the same validation logic to check the token's audience and claims. | |
* respondUnauthorized Function | |
* private fun JWTAuthenticationProvider.Config.respondUnauthorized() { ... }: This is a utility function that enhances the JWTAuthenticationProvider.Config with a custom response for unauthorized requests. | |
* | |
* challenge { _, _ -> ... }: Defines a challenge that is issued when authentication fails. In this case, it responds with UnauthorizedResponse(), indicating that the request was unauthorized. | |
* | |
* **/ | |
fun Application.configureSecurity( | |
jwtController: JwtController, | |
) { | |
install(Authentication) { | |
// | |
jwt("main_auth_jwt") { | |
verifier( | |
jwtController.verifyToken(tokenType = TokenType.ACCESS_TOKEN.name) | |
) | |
validate { credential -> | |
if (credential.payload.audience.contains(jwtController.audience) && credential.payload.claims.contains("User_id") ) { | |
JWTPrincipal(credential.payload) | |
} else | |
null | |
} | |
unauthorized() | |
} | |
jwt("refresh_auth_jwt") { | |
verifier(jwtController.verifyToken(tokenType = TokenType.REFRESH_TOKEN.name)) | |
validate { credential -> | |
if (credential.payload.audience.contains(jwtController.audience) && credential.payload.claims.contains("User_id") ) { | |
JWTPrincipal(credential.payload) | |
} else | |
null | |
} | |
unauthorized() | |
} | |
} | |
} | |
private fun JWTAuthenticationProvider.Config.unauthorized() { | |
challenge { _, _ -> | |
call.respond(UnauthorizedResponse()) | |
} | |
} |
Now it’s time to use the method we created, We’ll test this in a simple register function:
/** | |
* Authentication controller | |
[authDao] was created earlier, an interface that we use to communicate with our database | |
[jwt controller] a class for user authentication | |
[encryptor] an helper method for enrypting password string | |
* */ | |
class AuthController( | |
private val authDao: AuthDao, | |
private val jwt: JwtController, | |
private val encryptor: (String) -> String | |
) { | |
//register user | |
fun register(username: String, email: String, userType: String, password: String): AuthResponse { | |
return try { | |
validateRCredentials(username,email,password) | |
if (!authDao.isEmailTaken(email)) { | |
throw BadRequestException("Email already taken! Input another email") | |
} | |
val user = authDao.registerUser(username, email, userType, encryptor(password)) | |
AuthResponse.success(jwt.generateToken(user.id), "Registration successful") | |
} catch (BRE: BadRequestException) { | |
AuthResponse.failed(BRE.message) | |
} catch (UAE: UnauthorizedActivityException) { | |
AuthResponse.unauthorized(UAE.message) | |
} | |
} | |
fun validateRCredentials( | |
username: String, | |
email: String, | |
password: String, | |
) { | |
val message = when { | |
(username.isBlank() or password.isBlank()) -> "Username or password should not be blank" | |
(email.isBlank()) -> "Email cannot not be blank" | |
(username.length !in (4..30)) -> "Username should be of min 4 and max 30 character in length" | |
(password.length !in (8..50)) -> "Password should be of min 8 and max 50 character in length" | |
else -> return | |
} | |
throw BadRequestException(message) | |
} | |
} |
Job Offers
I will stop here because all the changes are just as the previous article, so let’s move to the client side
Client-Side Setup (Android Application)
The client side is pretty straightforward; what we just need to do is configure our Http Client (in this case, Ktor) to intercept the outgoing and incoming request:
fun generalHttpClient(): HttpClient { | |
val client = | |
HttpClient(getClientEngine()) { | |
expectSuccess = true | |
install(Logging) { | |
logger = Logger.DEFAULT | |
level = LogLevel.ALL | |
logger = | |
object : Logger { | |
override fun log(message: String) { | |
println("HTTP Client: $message") | |
} | |
} | |
} | |
install(ContentNegotiation) { | |
json( | |
Json { | |
prettyPrint = true | |
isLenient = true | |
ignoreUnknownKeys = true | |
}, | |
) | |
} | |
defaultRequest { | |
contentType(ContentType.Application.Json) | |
url(BASE_URL) | |
} | |
} | |
authorizationIntercept(client) | |
return client | |
} |
You will notice at line 31 there’s a method that takes in Http Client. Inside this function is where all the magic takes place:
/** | |
*Variables Initialization | |
* Client Configuration | |
* client.plugin(HttpSend).intercept { request -> ... }: Adds an interceptor to the HttpSend plugin of the HttpClient. This interceptor will intercept every HTTP request made by the client, allowing for custom logic to be executed before the request is sent and after the response is received. | |
* Access Token Handling | |
* val access = localDatabase.getAccessToken(mainRepo.getUserId).first(): Retrieves the current access token for the user from the local database. It assumes that getAccessToken returns a flow or collection, and .first() gets the first element. | |
* | |
* request.headers { append("Authorization", "Bearer $access") }: Modifies the HTTP request headers to include the current access token in the Authorization header. | |
* | |
* Response Status Check and Refresh Token Handling | |
* if (originalCall.response.status.value == 401 && access.isNotEmpty()) { ... }: Checks if the original HTTP request resulted in a 401 Unauthorized response and the access token is not empty. If so, it proceeds to handle the refresh token logic. | |
* val refreshToken = localDatabase.getRefreshToken(mainRepo.getUserId): Retrieves the current refresh token for the user. | |
* val (newAccess, newRefresh) = authRepo.getRefreshToken(refreshToken): Calls the authRepo to obtain new access and refresh tokens by using the current refresh token. | |
* localDatabase.saveAccessToken(newAccess, userId = mainRepo.getUserId): Saves the new access token to the local database. | |
* localDatabase.saveRefreshToken(newRefresh, userId = mainRepo.getUserId): Saves the new refresh token to the local database. | |
* execute(request): Re-executes the original HTTP request with the new access token. | |
* Original Call Return | |
* If the original HTTP request does not result in a 401 response or if there is no access token, it simply returns the result of the original HTTP call. | |
* ***/ | |
fun Scope.authorizationIntercept(client: HttpClient) { | |
// | |
val localDatabase: //init local database | |
val mainRepo: // init the repo | |
client.plugin(HttpSend).intercept { request -> | |
val access = localDatabase.getAccessToken(mainRepo.getUserId) | |
request.headers { | |
append("Authorization", "Bearer $access") | |
} | |
val originalCall = execute(request) | |
if (originalCall.response.status.value == 401 && access.isNotEmpty()) { | |
val refreshToken = localDatabase.getRefreshToken() | |
val (newAccess, newRefresh) = mainRepo.getRefreshToken(refreshToken) | |
localDatabase.saveAccessToken(newAccess) | |
localDatabase.saveRefreshToken(newRefresh) | |
execute(request) | |
} else { | |
originalCall | |
} | |
} | |
} |
After having all these setups, we make request using the Http Client and everything should work as expected.
Thanks for hanging on. I hope you find this information helpful and educational. If there’s anything you’re curious about or if I missed anything important, please don’t hesitate to reach out and ask!
I’m here to help, and I promise to respond to every comment. Let’s keep the conversation going!
Until next time, I’m Raheem Jr. Bye for now! 🖤
This article is previously published on proandroiddev.com