Handle missing refresh token in admin client - #51232
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Updates the Keycloak admin client to tolerate missing refresh_token values in token responses (notably for client_credentials) and preserves an existing refresh token when a refresh response omits it.
Changes:
- Make
refresh_tokenoptional in the token response typings. - Allow
setRefreshToken()to acceptundefinedand avoid decoding when missing. - Add tests covering missing refresh token on initial auth and on refresh.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| js/libs/keycloak-admin-client/test/client.spec.ts | Adds test coverage for missing/omitted refresh tokens using a local HTTP stub server. |
| js/libs/keycloak-admin-client/src/utils/auth.ts | Makes refresh token fields optional in token response interfaces. |
| js/libs/keycloak-admin-client/src/client.ts | Handles absent refresh tokens safely and keeps existing refresh token on refresh when omitted. |
Head branch was pushed to by a user without write access
c72c77a to
4edce93
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
js/libs/keycloak-admin-client/test/client.spec.ts:30
responsesis declared but not initialized before it’s used in the server request handler (responses.shift()), which can throwTypeError: Cannot read properties of undefinedif a request arrives before a test assignsresponses(or if a test forgets to). Initializeresponsesto an empty array (and consider resetting it inbeforeEach) so the handler fails gracefully with the intended 500 response instead of crashing.
let responses: object[];
before(async () => {
server = createServer((_req, res) => {
const response = responses.shift();
res.writeHead(response ? 200 : 500, {
"content-type": "application/json",
});
res.end(JSON.stringify(response ?? { error: "No response queued." }));
});
js/libs/keycloak-admin-client/src/client.ts:150
- Using a truthiness check here treats an empty string refresh token as “missing” and silently clears decoded state. If the intent is specifically “only skip decode when the token is absent,” check for
token !== undefined(ortypeof token !== \"undefined\") rather than truthiness so behavior matches the declared type (string | undefined).
public setRefreshToken(token?: string) {
this.refreshToken = token;
this.#refreshTokenDecoded = token ? decodeToken(token) : undefined;
js/libs/keycloak-admin-client/src/client.ts:190
- This condition is meant to detect “omitted refresh token,” but
if (refreshToken)is a truthiness check and will also skip updates for empty-string values. To align with the comment and withrefreshToken?: string, check explicitly for omission (e.g.,refreshToken !== undefined) so the logic is based on presence rather than truthiness.
// The current refresh token remains valid if the response does not carry a new one.
if (refreshToken) {
this.setRefreshToken(refreshToken);
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
js/libs/keycloak-admin-client/src/client.ts:190
if (refreshToken)treats an empty-string refresh token as “missing” and will incorrectly keep the old refresh token. Prefer checking explicitly for omission (e.g.,refreshToken !== undefined) so only a truly absent field keeps the prior token.
this.setAccessToken(accessToken);
// The current refresh token remains valid if the response does not carry a new one.
if (refreshToken) {
this.setRefreshToken(refreshToken);
}
js/libs/keycloak-admin-client/test/client.spec.ts:44
- The stubbed token endpoint responses don’t match the library’s declared
TokenResponseRawshape (many fields are required by type, but omitted in tests). To keep these tests robust against future implementation changes (e.g., if auth starts readingexpires_in,token_type, etc.), consider returning a more realistic full response object in the fixtures while still omittingrefresh_tokenin the relevant cases.
responses = [{ access_token: token(60) }];
js/libs/keycloak-admin-client/test/client.spec.ts:59
- The stubbed token endpoint responses don’t match the library’s declared
TokenResponseRawshape (many fields are required by type, but omitted in tests). To keep these tests robust against future implementation changes (e.g., if auth starts readingexpires_in,token_type, etc.), consider returning a more realistic full response object in the fixtures while still omittingrefresh_tokenin the relevant cases.
responses = [
{ access_token: token(-60), refresh_token: refreshToken },
{ access_token: renewedAccessToken },
];
4edce93 to
696d3bc
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
js/libs/keycloak-admin-client/test/client.spec.ts:37
closeAllConnections()was added in Node 18.2, while this package declares support for all Node versions>=18; on 18.0/18.1 the teardown throws aTypeError. Guard the optional cleanup method so the test still runs across the declared engine range.
server.closeAllConnections();
js/libs/keycloak-admin-client/src/client.ts:150
- The new auth test starts with an empty client, so it would still pass if a missing refresh token were handled as a no-op and a token from a previous
auth()remained. Add a regression case that authenticates once with a refresh token and then without one, asserting both the stored and decoded refresh-token state are cleared (for example viaisRefreshTokenExpired()after using an already-expired first token).
public setRefreshToken(token?: string) {
this.refreshToken = token;
this.#refreshTokenDecoded = token ? decodeToken(token) : undefined;
Keycloak only issues a refresh token for the `client_credentials` grant when the client is configured to, so `auth()` ended up passing undefined to `decodeToken()` and threw a `TypeError`. The refresh token is now optional in the token response types, and `setRefreshToken()` clears the decoded token instead of trying to decode nothing. The same applies when refreshing: if the token endpoint leaves the refresh token out of the response, the existing one is kept rather than cleared. Closes keycloak#50845 Signed-off-by: Robbie Blaine <robbieblaine.rb@gmail.com>
Head branch was pushed to by a user without write access
696d3bc to
388e9ca
Compare
Keycloak only issues a refresh token for the
client_credentialsgrant when the client is configured to, so
auth()ended up passingundefined to
decodeToken()and threw aTypeError. The refreshtoken is now optional in the token response types, and
setRefreshToken()clears the decoded token instead of trying todecode nothing.
The same applies when refreshing: if the token endpoint leaves the
refresh token out of the response, the existing one is kept rather
than cleared.
Closes #50845
Relates to #46834
Generative AI Disclosure:
Claude Opus 5 was used in this pull request.