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.
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}")
}
}
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" }
}
}
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?
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:
authenticate
) in a browserauthorizeUrl
passing necessary parametersurlProvider
lambda passing parameters required for acquiring an access tokenOAuthAccessTokenResponse
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)
}