Reputation: 51
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!
Upvotes: 5
Views: 2636
Reputation: 42497
UPDATE I originally wrote my solution around release 15 and the below works fine. We've since upgraded to version 20, and the solution below no longer works.
Things that appear to work:
Things that don't work:
audience
. While you can get this to issue a token, Keycloak preserves the azp
of the source client. If you're trying to get a refresh token, your target client can't refresh that token. Plus if you have any resource server that's expecting the target client in the azp
, they'll be disappointed.Currently, I don't think there is a good solution for this problem. You can find more details here. The work around my organization has employed has been non-OIDC/OAuth2-conformant REST APIs to do impersonation if the source client presents a good token and then impersonates the user as the target client, refreshes on behalf of the target client, etc. We're investigating an SPI to build out our own strategy but we don't have traction on that yet. Them's the breaks. It is a preview feature after all.
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)
Upvotes: 4