Ktor using OAuth 2.0 and IdentityServer4

Scott Brady
Scott Brady
Kotlin

This article will show you how to configure a Kotlin Ktor application to get access tokens from IdentityServer4 using OAuth 2.0. These tokens can then be used to access an API on behalf of a user. We’ll be using JWTs as our access tokens. To find out how to authorize access to a Ktor API using JWTs, check out my past article “JSON Web Token Verification in Ktor using Kotlin and Java-JWT”.

Kotlin logo Ktor logo IdentityServer logo

Ktor OAuth Support

Currently, Ktor only supports OAuth which means our Ktor application can receive access tokens to talk to an API on behalf of the user, but it cannot find out who the user is. If we wanted to find out who the user is and to receive identity tokens, we would need OpenID Connect, which is currently unsupported.

The Ktor OAuth library is hard-coded to support the authorization code flow. Ideally, in this scenario, we’d also like to use PKCE so that we can prevent authorization codes from other users being injected into our application.

Also, due to an oddity in the OAuth 2.0 specification, the Ktor basic authentication mechanism may not work with some authorization servers (basic auth is usually base64(client_id + ":" + client_secret) but OAuth defines it as base64(urlformencode(client_id) + ":" + urlformencode(client_secret)).

Only the query string response mode is supported, and the default value for state is random, utilizing a nonce (no more than once) generator.

Configuring OAuth in Ktor

To create my Ktor site I used the Ktor QuickStart guide, with an initial root page that displayed a button to trigger the OAuth process:

fun main(args: Array<String>) {
  embeddedServer(Netty, 8080) {
    routing {
      get("/") {
        call.respondText("""Click <a href="/oauth">here</a> to get tokens""", ContentType.Text.Html)
      }
    }
  }.start(wait = true)
}

First, initialize your OAuth 2 client settings using OAuth2ServerSettings:

val clientSettings = OAuthServerSettings.OAuth2ServerSettings(
  name = "IdentityServer4",
  authorizeUrl = "http://localhost:5000/connect/authorize", // OAuth authorization endpoint
  accessTokenUrl = "http://localhost:5000/connect/token", // OAuth token endpoint
  clientId = "ktor_app",
  clientSecret = "super_secret",
  // basic auth implementation is not "OAuth style" so falling back to post body
  accessTokenRequiresBasicAuth = false,
  requestMethod = HttpMethod.Post, // must POST to token endpoint
  defaultScopes = listOf("api1.read", "api1.write") // what scopes to explicitly request
)

If you want to customise the authorization request with extra parameters, then you can set the authorizeUrlInterceptor. For example:

authorizeUrlInterceptor = { this.parameters.append("response_mode", "query")}

You then need to register the OAuth authentication (ish) provider, installing it to the application like so:

install(Authentication) {
  oauth("IdentityServer4") {
    client = HttpClient(Apache)
    providerLookup = { clientSettings }
    urlProvider = { "http://localhost:8080/oauth" }
  }
}

Where “IdentityServer4” is the name of your authentication provider, client is a HttpClient that will handle the backchannel requests to the token endpoint, provider is our client settings from before, and urlProvider is the URL that will be configured to receive our authorization code. This will also serve as the redirect_uri value in our authorization request.

You then need to register this route while also applying your authentication provider. This serves to both start the authorization request and also handle the redirect URI and subsequent token request. At this point, we can also access the user principal. At the moment I’m just displaying it to the user (bad), while in reality, we would store the token for the user (maybe a cookie or persistent store somewhere).

routing {
  // other routes
  authenticate("IdentityServer4") {
    get("/oauth") {
      val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>()

      call.respondText("Access Token = ${principal?.accessToken}")
    }
  }
}

Configuring IdentityServer4 for Ktor

There’s nothing special you need to do in IdentityServer4 for Ktor support, and you can spin up an in-memory test instance using either the dotnet new is4inmem or following my getting started guide.

Your client configuration for Ktor should, therefore, look something like:

new Client
{
    ClientId = "ktor_app",
    AllowedGrantTypes = GrantTypes.Code,
    AllowedScopes = {"api1.read", "api1.write"},
    ClientSecrets = {new Secret("super_secret".Sha256())},
    RedirectUris = {"http://localhost:8080/oauth"}
}

You can find my config file on GitHub for comparison (I’ve just requested two scopes on an imaginary API).

You should now be able to log in as bob or alice and get access tokens in your Ktor application.

Note, if you receive a 401 from Ktor, it may be that the token request failed. Check your IdentityServer logs!

Source Code

You can find the full working solution for this article on GitHub, including a test instance of IdentityServer4.

Check out the Kotlin category on my blog for more OAuth and Kotlin meddling.