Keycloak: Impersonation mints token for wrong client

Viewed 1009

I'm trying to issue an access token through impersonation by using a trust client and issuing the token for a public client. I've set up the token exchange permissions and the request works. However, my problem is that the issued token seems to contain the wrong client in AZP.

The following is my request:

curl -v -X POST \
    -d "client_id=impersonator-client" \
    -d "client_secret=<secret omitted>" \
    --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
    -d "audience=target-client" \
    --data-urlencode "requested_subject=john.doe" \
    http://localhost:8080/auth/realms/swarm/protocol/openid-connect/token

Basically I want to get an access token for user "john.doe" by impersonating the user with the "impersonator-client". The issued token should be minted for "target-client", however, AZP still contains "impersonator-client".

The reason why I'm doing this is because it should be possible to log in using an external authentication workflow, that in the end provides an access token that can then be verified on my backend server, which uses the trusted client for impersonation.

According to the docs (https://www.keycloak.org/docs/latest/securing_apps/#_token-exchange) the audience should define the target client for which the token is supposed to be minted.

Regarding permissions:

I've set up the "admin-impersonating.permission.users" permission with a client policy that references the "impersonator-client". The "target-client" itself is configured with a permission "token-exchange.permission.client.e236d39c-9b9c-4815-b734-90364fea4e91" that includes a client policy references the "impersonator-client". Did I omit something there? The thing here is that the docs seem to be wrong. The docs use "user-impersonated.permission.users" instead of "admin-impersonating.permission.users". When I tried it like that, the request was even denied.

Is this a bug in Keycloak or did I do something wrong?

Thanks in advance!

1 Answers

OK, I ran into this issue and one of the biggest problems is that it prevented my app (the target-client) from performing the refresh token workflow. If I performed refresh_token grant using the expected target-client, I would get an error saying Session doesn't have the required grant. If I used starting-client (which matches the azp field in the token), I get Invalid refresh token. Token client and authorized client don't match.

I found this issue, which lead me to this, and it turns specifying the client_id of starting-client when asking for token exchange is what's causing the wrong azp on access and refresh tokens and messing everything up. I don't know if this is intended and the documentation is wrong, or this is a bug and I'm going to run into problems with it in the future. I'd watch those bug reports for more details in the future.

So here's the working way to do a token exchange (I'm using Keycloak 15.0.2) and then refresh from it. This is Kotlin but you get the idea. This assumes you have performed the necessary configuration here.

// tokenManager makes sure I have a valid access token from "starting-client"
// via client_credentials grant. You can swap with however you want to get this token.
val adminToken = keycloak.tokenManager().accessTokenString

// exchange your admin token for starting-client for a user token at target-client
val response = Unirest.post("${keycloakProperties.authServerUrl}/realms/${keycloakProperties.realm}/protocol/openid-connect/token")
            .field("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") // using the keycloak custom token-exchange grant
            .field("client_id", "target-client") // set to the target client ID, not the starting as the docs say!
            .field("subject_token", adminToken) // seems the exchange grant reads the starting-client from the subject token, so setting "client_id" to "starting-client" (which is what I had been doing) forces the exchanged token into an invalid state
            .field("requested_token_type", "urn:ietf:params:oauth:token-type:refresh_token") // refresh_token will issue you both an access and refresh token
            .field("audience", "target-client")
            .field("requested_subject", userId) // the keycloak user ID we are requesting a token for
            .asObject(AccessTokenResponse::class.java) // this is just some DTO so I can deserialize the JSON into an object for easy use, I happened to have this from the Keycloak admin client laying around
            .body

// we have our access token now. let's refresh it for the sake of the example, but normally your app would do this periodically on the client end.
val refreshedToken = Unirest.post("${keycloakProperties.authServerUrl}/realms/${keycloakProperties.realm}/protocol/openid-connect/token")
            .field("grant_type", "refresh_token")
            .field("client_id", "target-client")
            .field("refresh_token", accessToken.refreshToken)
            .asObject(AccessTokenResponse::class.java)
            .body

// voila, refreshedToken has given you a new access and refresh token
log.debug("New access token {}, refresh token {}", refreshedToken.accessToken, refreshedToken.refreshToken)
Related