Search code examples
kotlinauthenticationoauth-2.0keycloakktor

OAuth with KeyCloak in Ktor : Is it supposed to work like this?


I tried to set up a working Oauth2 authorization via Keycloak in a Ktor web server. The expected flow would be sending a request from the web server to keycloak and logging in on the given UI, then Keycloak sends back a code that can be used to receive a token. Like here

First I did it based on the examples in Ktor's documentation. Oauth It worked fine until it got to the point where I had to receive the token, then it just gave me HTTP status 401. Even though the curl command works properly. Then I tried an example project I found on GitHub , I managed to make it work by building my own HTTP request and sending it to the Keycloak server to receive the token, but is it supposed to work like this?

I have multiple questions regarding this.

  1. Is this function supposed to handle both authorization and getting the token?

     authenticate(keycloakOAuth) {
         get("/oauth") {
             val principal = call.authentication.principal<OAuthAccessTokenResponse.OAuth2>()
    
             call.respondText("Access Token = ${principal?.accessToken}")
         }
     }
    
  2. I think my configuration is correct, since I can receive the authorization, just not the token.

    const val KEYCLOAK_ADDRESS = "**"
    
    val keycloakProvider = OAuthServerSettings.OAuth2ServerSettings(
    name = "keycloak",
    authorizeUrl = "$KEYCLOAK_ADDRESS/auth/realms/production/protocol/openid-connect/auth",
    accessTokenUrl = "$KEYCLOAK_ADDRESS/auth/realms/production/protocol/openid-connect/token",
    clientId = "**",
    clientSecret = "**",
    accessTokenRequiresBasicAuth = false,
    requestMethod = HttpMethod.Post, // must POST to token endpoint
    defaultScopes = listOf("roles")
    )
    const val keycloakOAuth = "keycloakOAuth"
    
     install(Authentication) {
         oauth(keycloakOAuth) {
         client = HttpClient(Apache)
         providerLookup = { keycloakProvider }
         urlProvider = { "http://localhost:8080/token" }
     }
    }
    
  3. There is this /token route I made with a built HTTP request, this one manages to get the token, but it feels like a hack.

    get("/token"){
     var grantType = "authorization_code"
     val code = call.request.queryParameters["code"]
     val requestBody = "grant_type=${grantType}&" +
             "client_id=${keycloakProvider.clientId}&" +
             "client_secret=${keycloakProvider.clientSecret}&" +
             "code=${code.toString()}&" +
             "redirect_uri=http://localhost:8080/token"
    
     val tokenResponse = httpClient.post<HttpResponse>(keycloakProvider.accessTokenUrl) {
         headers {
             append("Content-Type","application/x-www-form-urlencoded")
         }
         body = requestBody
     }
     call.respondText("Access Token = ${tokenResponse.readText()}")
    }
    

TL;DR: I can log in via Keycloak fine, but trying to get an access_token gives me 401. Is the authenticate function in ktor supposed to handle that too?


Solution

  • The answer to your first question: it will be used for both if that route corresponds to the redirect URI returned in urlProvider lambda.

    The overall process is the following:

    1. A user opens http://localhost:7777/login (any route under authenticate) in a browser
    2. Ktor makes a redirect to authorizeUrl passing necessary parameters
    3. The User logs in through Keycloak UI
    4. Keycloak redirects the user to the redirect URI provided by urlProvider lambda passing parameters required for acquiring an access token
    5. Ktor makes a request to the token URL and executes the routing handler that corresponds to the redirect URI (http://localhost:7777/callback in the example).
    6. In the handler you have access to the OAuthAccessTokenResponse object that has properties for an access token, refresh token and any other parameters returned from Keycloak.

    Here is the code for the working example:

    val provider = OAuthServerSettings.OAuth2ServerSettings(
        name = "keycloak",
        authorizeUrl = "http://localhost:8080/auth/realms/master/protocol/openid-connect/auth",
        accessTokenUrl = "http://localhost:8080/auth/realms/$realm/protocol/openid-connect/token",
        clientId = clientId,
        clientSecret = clientSecret,
        requestMethod = HttpMethod.Post // The GET HTTP method is not supported for this provider
    )
    
    fun main() {
        embeddedServer(Netty, port = 7777) {
            install(Authentication) {
                oauth("keycloak_oauth") {
                    client = HttpClient(Apache)
                    providerLookup = { provider }
                    // The URL should match "Valid Redirect URIs" pattern in Keycloak client settings
                    urlProvider = { "http://localhost:7777/callback" }
                }
            }
    
            routing {
                authenticate("keycloak_oauth") {
                    get("login") {
                        // The user will be redirected to authorizeUrl first
                    }
    
                    route("/callback") {
                        // This handler will be executed after making a request to a provider's token URL.
                        handle {
                            val principal = call.authentication.principal<OAuthAccessTokenResponse>()
    
                            if (principal != null) {
                                val response = principal as OAuthAccessTokenResponse.OAuth2
                                call.respondText { "Access token: ${response.accessToken}" }
                            } else {
                                call.respondText { "NO principal" }
                            }
                        }
                    }
                }
            }
        }.start(wait = false)
    }