keycloak: Token Exchange setting incorrect clientId

Describe the bug

I’m attempting to do an internal-to-internal token exchange. The exchange returns successfully, but it appears that the issuedFor or azp claim is set to the incorrect client.

This poses a problem in that the exchanged token is not able to be refreshed with its access token because the refresh token is bound to the wrong client.

I’m exchanging the token so that I can get the scopes, etc from the target client. That works great here, but I then need to be able to use the refresh token to exchange it for a new access token that has those same scopes. In the current state, I instead have to re-initiate the token exchange all over again.

I’m I understanding token exchange correctly here? I took it that you take a token from client A and are able to completely exchange it for a token from client B. The way this is currently set up, the token is exchanged for another client A token, but that has been enriched with scopes from client B. Also, why is no identity token returned from the exchange?

Version

15.0.2

Expected behavior

The Access Token and Refresh token should have the azp set to the target client.

Actual behavior

The Access Token and Refresh token are set to the starting client.

How to Reproduce?

To reproduce, simply follow the internal-to-internal token exchange docs and inspect the tokens that are returned. The azp will be set to the starting client.

Once the tokens have been exchanged, attempt to issue a refresh against the target-client.

POST /realms/:realm/protocol/openid-connect/token

grant_type: refresh_token
refresh_token: <token>
client_id: target-client

Response:

{
    "error": "invalid_grant",
    "error_description": "Invalid refresh token. Token client and authorized client don't match"
}

This makes sense given the azp is for the starting-client, not the target.

Anything else?

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 12
  • Comments: 32 (9 by maintainers)

Most upvoted comments

There is also a third line that needs a fix - for refresh token it looks at source client if it’s allowed, not target.

This is what confuses me a lot. What is the sense in being able to request a refresh token (via requested_token_type: urn:ietf:params:oauth:token-type:refresh_token), but not being able to refresh the token?

Allowing public clients to exchange tokens opens security holes and that is the reason behind the last updates we did in this area. Conceptually, it is also wrong because a public client is conceptually a client and not a resource server.

You might consider looking at some SPA architectures in this specification. In particular, the first one is related to both SPA and API living within the same domain.

Another possible approach is to provide your own custom TokenExchangeProvider implementation so that you can support your legacy system as an additional subject token type.

Preface: I no longer work at the company where I was utilizing this technology.

But the setup was such that we had two clients that we would authenticate clients through. The first (or starting) client was used to do the initial authentication. Then we would perform a token swap to the second client based on the company account the user was operating under. This would swap out the token and give them more specific scopes that was appropriate for the account they were acting as.

I assumed the token exchange was a true exchange such that it was as if the user fully authenticated through the target client, not just swapped.

But it seems to me that the swapped token is still bound in some way to the original authenticated client. This makes the refresh logic a bit more complex because the original token doesn’t become throw away such that we have to continually issue another swap once the swapped token expires.

I just came across this Keycloak issue and it seems related: KEYCLOAK-19116

ncept of a hybrid client that allowed for a service account attached to a public client … or essentially allowing limited public mode (secretless) intera

After putting in quite some effort into setting up the token exchange logic, so an admin user could impersonate another user. And working through the hurdles of the fact it is also still semi in preview, I also hit upon this issue. The flow from within Keycloak sadly was and is too difficult for the support staff of the project. And this seemed like a nice, sensible way to go about it. First, I request the API server to exchange the token for the user and then refresh the client with the newly received tokens using the Keycloak.init() method. This sadly always results in:

<<keycloak url>>/realms/<<realm>>/protocol/openid-connect/token 400 bad request Invalid refresh token. Token client and authorized client don't match

Meaning, like most others here, I would have to build a BFF-proxy just for one piece of functionality that I would be more than comfortable configuring through a setting. Allowing the confidential client to exchange the token to the public client with which the users on the frontend login. With the correct AUZ

update:

For now at least till the end of the year we opted to overwrite the TokenExchangeProvider based on the DefaultTokenExchangeProvider. And modify the AUZ directly within it by changing lines:

responseBuilder.accessToken.issuedFor(targetClient.clientId)

And

responseBuilder.refreshToken.issuedFor(targetClient.clientId)

Within the exchangeClientToOIDCClient method

Similar problem here. 😕

What I want to do is to use a confidential client to create a new user via POST request to the /admin/realms/{realm}/users endpoint, and then use the token exchange feature to acquire an access and refresh token for this newly created user, but bound to another, public client instead (as these tokens are intended to be used in our frontend).

With Keycloak 17 Legacy, I got it to work using the method described by @moribvndvs, namely putting the target client_id into both the audience and client_id fields and removing the client_secret field. This way I received a token that has both the source and target client IDs in its aud property and the target client ID in its azp property.

After upgrading to Keycloak 18 however, this workaround does not work anymore and the call to the token exchange endpoint results in access_denied: Client is not the holder of the token. Without the workaround (with having the source client ID in the client_id field and adding the client_secret field again, just as described in the documentation for the token exchange), it’s back to the original behavior, having both the source and target client IDs in its aud property, but the source client ID in azp. When I try to use this token with the target client, for example to refresh it on the frontend side, I get the same error @EmadiGhobad described, namely Invalid refresh token. Token client and authorized client don't match.

Now I ask myself: Is that, what I want to do, even intended to be possible? 🤔 Means: Is it a bug in Keycloak, or is it just not possible to use a confidential client to acquire a token for another, public client?

There isn’t. That’s the conclusion I reached after two days of trialing potential alternatives.

So today I had to set up a maven project, extend default spi (annoying since you can’t access lots of private properties). Build and deploy jar - with default as ID (docker build spi override refused to swap to custom id).

It now needs rebuild and testing after every kc update.

There is also a third line that needs a fix - for refresh token it looks at source client if it’s allowed, not target.

I am trying to use the external to internal token exchange to import an user from an external idp and then use the generated token in a web application using the JS adapter.

When I try to init the javascript adapter with the generated token I encounter the same issue with the refresh token.

What would be the correct way to implement my use case?

For now as a workaround I am considering implementing a custom TokenExchangeProvider SPI that sets the azp to the targetClient, but I would prefer a more robust and maintenable solution.

I think the most common use case is simply one of conferring identity … I mean moving from confidential to public that is all I would assume … for that scenario you are only protecting what you are comfortable protecting via a very public contract and so simply being able to steer the audience correctly while providing a access refresh and id token … that probably solves a majority of the use cases at least from what I can glean from the comments here.

Even if it requires a second token exchange using the public client via the access token that spans from the confidential to public client that would probably be acceptable to most.

Private Client -> [public client azp access_token] -> public client -> [properly based access refresh and (id) ]

? I mean the two problems that were blockers were the azp not including the destination client when setting the audience and then the fact that the refresh token pointed to the original client … so the above scenario … while it invokes an extra non requested subject exchange … still keeps things sane ?

@NeoVG It does not make sense to exchange a token from a confidential client for a public client.

BTW, we found a workaround, at least kind of:

Instead of initiating the token exchange with an access token belonging to a service account of the confidential client (that was acquired using client id and secret), I’m now using the public client to acquire an access token for an actual user (the “superadmin” 😉), who has the permission to impersonate every other user in the realm (using its username and password to login), and use that one to initiate the token exchange. Since now the source and target clients are the same, it works as intended.

Problems with this solution are 1) that the public client needs to have direct access grants enabled (which might not be an option for everyone) and 2) that this “superadmin” user might be a security issue.

Same issue here. I have also noted the azp is for the starting client rather than the target-client. If, in the refresh call, I reference the expected target-client as the client_id, I get the same error. If I try to use the starting client instead (to match the azp), I get a different error:

{
    "error": "invalid_grant",
   "error_description": "Session doesn't have required client"
}

However, as @nickzelei pointed out, I do seem to be able to get this to work if I change around my token exchange workflow.

First, I am using client_credential token for starting-client and exchanging it for a specific user’s token for target app:

        // tokenManager is set up to keep me flush with a valid access token for starting-client using client_credential flow
        val accessToken = keycloak.tokenManager().accessTokenString

        // now, we build our request to exchange the admin token for a user's token
        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")
            .field("subject_token", accessToken) // 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")
            .field("audience", "target-client")
            .field("requested_subject", userId) // the keycloak user ID we are requesting a token for
            .asObject(AccessTokenResponse::class.java)

This seems to fix the AZP, and now I can use the refresh token from that response:

val response = Unirest.post("${keycloakProperties.authServerUrl}/realms/${keycloakProperties.realm}/protocol/openid-connect/token")
            .field("grant_type", "refresh_token")
            .field("client_id", "target-client")
            .field("refresh_token", refreshToken)
            .asObject(AccessTokenResponse::class.java)

So yeah, I dunno if we’ve run into a bug or the documentation needs updated. However, this appears to be working well for me for now. /shrug