Implementing Stateless OAuth in Ktor Using Google and JWT
In our previous post, we explored the theory behind OAuth 2.0. Today, we’re going to dive into practical application by implementing OAuth authentication with Google in a Ktor application. While the traditional approach leans towards using user session cookies to store access tokens, we’re taking a different approach. We’ll be leveraging JSON Web Tokens (JWT) to encapsulate the access token. By encapsulating the access token in JWT, we transition our authentication to a stateless model, making our server more resource efficient.
This post is part of a hands-on tutorial. Feel free to check out the project from GitHub and follow along.
Table of Contents
- What Are We Going to Build?
- How Does OAuth and JWT Work Together?
- Starting Point? Register Your App with Google
- Core Dependencies
- Security Configuration
- Routing
- Getting User Information from Google
- Summary
What Are We Going to Build?
Today, we’re going to implement a common use case. Suppose you’d like to open your platform (whatever it might be) to a wider audience. To facilitate this, you decide to incorporate social login options, such as Google, Facebook, and LinkedIn. What does this entail? It means that, instead of managing user credentials directly, your system defers identity verification to a trusted external authority, which then shares basic user information with your platform. This approach, highly useful and widely adopted, simplifies user access while maintaining a high degree of security.
How Does OAuth and JWT Work Together?
In our implementation we offload most of OAuth intricacies to an external provider, Google. At the same time, we use JWT to ensure stateless authentication on our server. Here is the gist of it:
- OAuth Authentication with an External Provider: User authenticates through a social OAuth provider, namely with Google. The OAuth provider verifies the user’s credentials and eventually sends an access token back to our server.
- Embedding OAuth Token in JWT: Our server accepts the access token, creates a JWT, and then includes the access token from Google as a claim within this JWT.
- Sending JWT to the Client: Our server then sends the JWT (which contains the Google access token as a claim) to the client.
- Stateless Authentication: Whenever the client makes a request to your server, it includes the JWT as a bearer token in the request header. There’s no need to store a session cookie on the server.
- Extracting the Access Token: Upon receiving the request, our server verifies the JWT and extracts the Google access token from it.
- Interaction with Google: The Google access token is then used by our server to discreetly fetch user information from Google.
This process ensures that:
- The client does not have direct access to the Google access token, embracing security.
- The server uses a stateless authentication process, enhancing scalability and performance.
- The server retains the ability to fetch necessary user data from Google.
Starting Point? Register Your App with Google
This step has been extensively documented, so I won’t waste your time when reading this post. You can follow a step-by-step guide on Ktor website.
Once done, copy the provided credentials: client ID
and client secret
, and store them in a secret manager (both GCP and AWS provide one) or in a vault accessible from your server.
Word of caution: Hardcoding these credentials either in your code or configuration is a bad idea. Always access them through environment variables or some other placeholder.
Core Dependencies
We will rely on both OAuth and JWT where OAuth is included in the ktor-server-auth
plugin. Here is what you need to add to your build.gradle.kts
file:
implementation("io.ktor:ktor-server-auth:$ktor_version")
implementation("io.ktor:ktor-server-auth-jwt:$ktor_version")
Security Configuration
We need to configure both JWT and OAuth. Our configuration DSL looks as follows:
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
authentication {
jwt("jwt-auth") {
// Configure JWT
}
oauth("google-oauth") {
// Configure OAuth as per Google's instructions
}
}
The source code is available on GitHub – see Security.kt.
In terms of configuring JWT, there’s nothing new to add beyond what we’ve already explored in Part 6 of this series. If you need a refresher, it might be beneficial to revisit this section.
Configuring OAuth is straightforward enough. Here are the main configuration properties:
- urlProvider: Refers to the callback endpoint. This means that after the user grants your application permission to access their details, Google redirects them to this endpoint, passing a one-time access code, so called authorization code. Your server then exchanges this code for a long-lived access token.
- authorizeUrl: This is the endpoint to which your application redirects a user in the initial step of the OAuth authorization process. The value corresponds to Google’s authorization server URL.
- accessTokenUrl: This is an endpoint that you call to exchange the one-time code for a long-lived access token.
- clientId and clientSecret: The credentials you received from Google when registering your application. Needless to say, you shouldn’t really hardcode these values. Instead, read them from environment variables.
- httpClient: Ktor will use the client to make calls to Google endpoints.
oauth("google-oauth") {
urlProvider = { "http://localhost:8080/callback" }
providerLookup = {
OAuthServerSettings.OAuth2ServerSettings(
name = "google-oauth",
authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
accessTokenUrl = "https://www.googleapis.com/oauth2/v3/token",
requestMethod = HttpMethod.Post,
clientId = "your-client-id",
clientSecret = "your-client-secret",
defaultScopes = listOf(
"https://www.googleapis.com/auth/userinfo.profile"
)
)
}
client = httpClient
}
Ktor will handle all communication with Google based on the provided config. You don’t need to worry about subtle details, such as intercepting the authorization code and exchanging it for an access token.
Routing
As you might have guessed, our routes will be shield both by JWT and OAuth authentication. Here is an outline of our routing:
authenticate("jwt-auth") {
get("/me") {
// info about the user
}
}
authenticate("google-oauth") {
get("/login") {
// Automatically redirects to Google's authorization URL
}
get("/callback") {
// Ktor redirects here after a successful exchange
// of one-time code for an access token
}
}
Check the source code on GitHub – Routing.kt. However, we don’t really have to do much. The login functionality is completely handled by Ktor. Our main focus is now the statelessness of our authorization process. Meaning, instead of keeping the access token in a session cookie, we want to add it as a claim in a JWT token.
Simply put, we extract the user’s access token from the incoming request and generate a JWT token, which we then return to the client as part of our response. In this example, we opt for a plain text response for simplicity. However, in a real-world project, it would be more practical to redirect to a designated landing page.
The client application is then responsible for intercepting this token, securely storing it (e.g., in a HTTPOnly cookie or a local storage, depending on the security requirements and the application structure) and including it in the Authorization header of subsequent requests to our server. This ensures a secure and seamless authentication experience for the user.
get("/callback") {
(call.principal() as OAuthAccessTokenResponse.OAuth2?)?.let {
principal ->
val accessToken = principal.accessToken
val jwtToken = createToken(clock, accessToken, 3600)
call.respondText(jwtToken, contentType = ContentType.Text.Plain)
}
}
The createToken
function is implmented as follows. Feel free to read Implementing User Authentication for a refresher:
JWT.create()
.withAudience("your-audience")
.withIssuer("your-issuer")
.withClaim("google_access_token", accessToken)
.withExpiresAt(clock.instant().plusSeconds(expirationSeconds))
.sign(Algorithm.HMAC256("your-secret"))
Getting User Information from Google
Last but not least comes the interesting part – retrieving user information from Google. This is where the /me
endpoint comes into play. To access this endpoint, clients must provide a JWT token which encapsulates Google’s access token.
get("/me") {
val principal = call.principal<JWTPrincipal>() ?: run {
call.respond(HttpStatusCode.Forbidden, "Not logged in")
return@get
}
val accessToken = principal.getClaim(
"google_access_token",
String::class) ?: run {
call.respond(HttpStatusCode.Forbidden, "No access token")
return@get
}
val userInfo = getUserInfo(accessToken, oauthConfig, httpClient)
call.respondText("Hi, ${userInfo.name}!")
}
The implementation of getUserInfo
involves the HTTP client.
private suspend fun getUserInfo(
accessToken: String,
httpClient: HttpClient): UserInfo =
httpClient.get("https://www.googleapis.com/oauth2/v1/userinfo") {
headers {
append("Authorization", "Bearer $accessToken")
}
}.body()
UserInfo
is a custom data class that contains curated information about a user.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserInfo(
val id: String,
val name: String,
@SerialName("given_name")
val givenName: String,
@SerialName("family_name")
val familyName: String,
val picture: String,
val locale: String
)
This approach provides several advantages. Our server acts as an intermediary, providing an added layer of control over the access to user information. This enables our system to:
- Keep detailed usage metrics and an audit log, which are invaluable for monitoring and understanding user interactions.
- Impose additional security measures and custom access restrictions to improve our application’s security posture.
- Ensure data privacy by filtering unnecessary details before they reach the client, offering a more secure and tailored user experience.
This setup effectively leverages our server to manage and safeguard access to sensitive user information while delivering it through our own secure and audited channels.
Summary
Congratulations on making it to the end of this post! By now, you should have a solid understanding of how to implement OAuth using Ktor and an external authority such as Google. You’ve also learned how to keep the authentication process stateless, a strategy that preserves server resources. Additionally, we’ve explored how to access user details securely without giving up control on the server side. I appreciate your engagement throughout this exploration and see you around next time.