Skip to content

Conversation

ancheetah
Copy link
Collaborator

JIRA Ticket

https://pingidentity.atlassian.net/browse/SDKS-4348

Description

  • Implements token.revoke method in OIDC client
  • Adds e2e tests for revoke
  • Updates response type for user.info endpoint and client method

Changeset added

Copy link

changeset-bot bot commented Aug 26, 2025

🦋 Changeset detected

Latest commit: c48d9c8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@forgerock/oidc-client Minor
@forgerock/davinci-client Minor
@forgerock/protect Minor
@forgerock/sdk-types Minor
@forgerock/sdk-utilities Minor
@forgerock/iframe-manager Minor
@forgerock/sdk-logger Minor
@forgerock/sdk-oidc Minor
@forgerock/sdk-request-middleware Minor
@forgerock/storage Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link

nx-cloud bot commented Aug 26, 2025

View your CI Pipeline Execution ↗ for commit c48d9c8

Command Status Duration Result
nx run-many -t build ✅ Succeeded <1s View ↗
nx affected -t build typecheck lint test e2e-ci ✅ Succeeded 4m 17s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 1s View ↗

☁️ Nx Cloud last updated this comment at 2025-09-05 21:17:15 UTC

@codecov-commenter
Copy link

codecov-commenter commented Aug 26, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.47%. Comparing base (4d4be08) to head (c48d9c8).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #376   +/-   ##
=======================================
  Coverage   55.47%   55.47%           
=======================================
  Files          32       32           
  Lines        2044     2044           
  Branches      340      340           
=======================================
  Hits         1134     1134           
  Misses        910      910           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Contributor

github-actions bot commented Aug 26, 2025

Deployed 218d5be to https://ForgeRock.github.io/ping-javascript-sdk/pr-376/218d5be77b793e1e5114e58f2494fa19e21258af branch gh-pages in ForgeRock/ping-javascript-sdk

Copy link
Contributor

github-actions bot commented Aug 26, 2025

📦 Bundle Size Analysis

📦 Bundle Size Analysis

📊 Minor Changes

📈 @forgerock/oidc-client - 23.1 KB (+0.7 KB)

➖ No Changes

@forgerock/davinci-client - 34.1 KB
@forgerock/sdk-utilities - 4.0 KB
@forgerock/device-client - 9.2 KB
@forgerock/sdk-types - 5.9 KB
@forgerock/protect - 150.1 KB
@forgerock/sdk-oidc - 2.7 KB
@forgerock/sdk-logger - 1.6 KB
@forgerock/storage - 1.4 KB
@forgerock/sdk-request-middleware - 4.4 KB
@forgerock/iframe-manager - 2.4 KB


11 packages analyzed • Baseline from latest main build

Legend

🆕 New package
🔺 Size increased
🔻 Size decreased
➖ No change

ℹ️ How bundle sizes are calculated
  • Current Size: Total gzipped size of all files in the package's dist directory
  • Baseline: Comparison against the latest build from the main branch
  • Files included: All build outputs except source maps and TypeScript build cache
  • Exclusions: .map, .tsbuildinfo, and .d.ts.map files

🔄 Updated automatically on each push to this PR

@ancheetah ancheetah requested review from cerebrl and ryanbas21 August 26, 2025 18:23
Copy link
Collaborator

@ryanbas21 ryanbas21 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, just a few comments / questions

},
}),
userInfo: builder.mutation<TokenExchangeResponse, { accessToken: string; endpoint: string }>({
userInfo: builder.mutation<unknown, { accessToken: string; endpoint: string }>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we making this change?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can revert this but I thought the TokenExchangeResponse type was a mistake. The user endpoint doesn't return tokens, it returns a user object which varies depending on scope. I wasn't sure if we were guaranteed a Record<string,string> so I followed what we did in the old SDK and typed it as unknown.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the original type was an error, but I don't want to use unknown. The structure of the response is known, though much of it is optional. It's defined by the spec. Let's provide a more explicitly structured object, instead of unknown.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the spec for PingOne server which only requires sub in the response. Is there a spec for PingAM server somewhere?

https://apidocs.pingidentity.com/pingone/auth/v1/api/#get-userinfo-get

}),
// Delete local token and return combined results
Micro.flatMap((revokeResponse) =>
Micro.promise(async () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing wrong with this, but in my brain when I read this it feels semi-redundant to make the callback async, await a promise, and then return an object, in a micro.

I think of it more in sequences like so:

Micro.flatMap(revokeResponse => 
  Micro.promise(() => storageClient.remove()),
  Micro.map(deleteResponse => ({ deleteResponse, revokeResponse }))
 )

Like I said this is more of a nitpick so feel free to ignore.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated it and added a pipe. Please take a look.

}),
),
).pipe(
Micro.map(({ error }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another nitpick - but i'm a little against this pattern we somewhat have of handling errors in the map.

Because RTK doesn't explicitly error, it appears like these are successful sequences.

I think we already have the logic though to move these to the error channel.

if we have an error, we should fail the effect (micro).

if (error) {
       return Micro.fail(error)
    }
).pipe(
Micro.mapError(error => {
  // error logic here
}),

Now, because we don't use _tag to identify errors, I don't think we can use catchTags but thats okay for now I think.

Why would I suggest this?

The pattern itself allows us to handle errors differently or the same for a given effect. While in this effect it may not be perfectly represented here is the thought.

  • build a reusable effect
  • produce a failure when the effect fails
  • allows the consumer of the effect (Micro) to handle the error how the consumer wants to.
  • doesn't conflate successful sequences with errors.

Building in an error into the successful computations, in my eyes, is an anti-pattern.

One may argue, that because the error itself, is a value, its "successful" but this is what Micro/Effect already does for us. It branches these two channels, and still returns them as a value.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we discuss this as a team at a later time? I think I understand your intention here, but I'd like to be sure we test this out together and ensure we are on the same page with the desired technical outcome. I'd encourage us to go ahead and merge this as it's consistent with the existing pattern.

After the discussion, we can update both to be consistent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was challenging for me so I think I will leave this refactoring for another time unless you have a better suggestion. I agree that we should Micro.fail when we come across an error but I couldn't find a way to do this gracefully. Here's what I came up with which we can discuss another time:

        const revokeNew = Micro.zip(
          // Revoke the access token
          Micro.promise(() =>
            store.dispatch(
              oidcApi.endpoints.revoke.initiate({
                accessToken: tokens.accessToken,
                clientId: config.clientId,
                endpoint: wellknown.revocation_endpoint,
              }),
            ),
          ),
          // Delete local token
          Micro.promise(() => storageClient.remove()),
        ).pipe(
          Micro.flatMap(([revokeResponse, deleteResponse]) => {
            const { error } = revokeResponse;
            if (error) {
              return Micro.fail(error);
            }
            return Micro.succeed({ revokeResponse: null, deleteResponse });
          }),
        );

        const revokeResult = Micro.mapError(revokeNew, (error) => {
          let message = 'An error occurred while revoking the token';
          let status: number | string = 'unknown';
          if ('message' in error && error.message) {
            message = error.message;
          }
          if ('status' in error) {
            status = error.status;
          }
          return {
            revokeResponse: {
              error: 'Token revocation failure',
              message,
              type: 'auth_error',
              status,
            } as GenericError,
            deleteResponse: undefined,
          };
        });

        const result = await Micro.runPromiseExit(revokeResult);

        if (exitIsSuccess(result)) {
          return result.value;
        } else if (exitIsFail(result)) {
          return result.cause.error;
        } else {
          return {
            error: 'Token revocation failure',
            message: result.cause.message,
            type: 'auth_error',
          };
        }

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure we can discuss later.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for future reference, this should do what I am suggesting

const revokeµ = Micro.promise(() =>
          store.dispatch(
            oidcApi.endpoints.revoke.initiate({
              accessToken: tokens.accessToken,
              clientId: config.clientId,
              endpoint: wellknown.revocation_endpoint,
            }),
          ),
        ).pipe(
          Micro.flatMap(({ error }) => {
            if (error) {
              return Micro.fail(error);
            }

            return Micro.succeed(null);
          }),
          Micro.mapError((error) => {
            let message = 'An error occurred while revoking the token';
            let status: number | string = 'unknown';
            if ('message' in error && error.message) {
              message = error.message;
            }
            if ('status' in error) {
              status = error.status;
            }
            return {
              error: 'Token revocation failure',
              message,
              type: 'auth_error',
              status,
            } as GenericError;
          }),
          // Delete local token and return combined results
          Micro.flatMap((revokeResponse) =>
            Micro.promise(() => storageClient.remove()).pipe(
              Micro.map((deleteResponse) => ({ revokeResponse, deleteResponse })),
            ),
          ),
        );

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks, Ryan. Now that we have a complete sample of code, we can discuss the pros and cons. Appreciate the effort here :)

return null;
}),
// Delete local token and return combined results
Micro.flatMap((revokeResponse) =>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 good use of flatMap

@ancheetah ancheetah force-pushed the SDKS-4348-token-revoke branch from e6f746d to c7490ee Compare August 26, 2025 21:10
const userInfo = await oidcClient.user.info();

if ('error' in userInfo) {
if (typeof userInfo === 'object' && 'error' in userInfo) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is TypeScript communicating that userInfo might not be an object? If so, something is wrong as it should either be a user info object or a generic error.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was because I changed the user info return type to unknown. This will change though once I look up the spec and better define the return type

const response = await oidcClient.token.revoke();
const isError = checkLogoutOrRevokeError(response);

if (isError) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a root property on the object that signals an error within one of the responses?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be helpful. Something like isError: boolean;. But I think it would be nicer if we returned an error type if error and a success type if success. Something like:

type RevokeResult = {
  revokeResponse: null;
  deleteResponse: void;
};

type RevokeError = {
  error: {
    revokeResponse: GenericError;
    deleteResponse: void;
  };
};

In which case RevokeResult seems silly and we can get rid of it altogether. However, these sort of types would require a refactor of revoke and logout.

Comment on lines 174 to 175
await expect(page.locator('#accessToken-0')).not.toBeEmpty();
await expect(page.locator('#accessToken-0')).not.toHaveText('undefined');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need both of these? Could we improve how we handle the DOM so "undefined" is not injected.

Comment on lines 212 to 227
await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/');

await page.getByLabel('User Name').fill(pingAmUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword);
await page.getByRole('button', { name: 'Next' }).click();

await page.waitForURL('http://localhost:8443/ping-am/**', { waitUntil: 'networkidle' });
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');

await expect(page.locator('#accessToken-0')).not.toBeEmpty();
await expect(page.locator('#accessToken-0')).not.toHaveText('undefined');

await page.evaluate(() => window.localStorage.clear());
const token = await page.evaluate(() => localStorage.getItem('pic-oidcTokens'));
await expect(token).toBeFalsy();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to go through this whole login flow to then delete the token to test this error case? Could we do what's done just above?

Comment on lines 66 to 78
await clickButton('Login (Background)', 'https://openam-sdks.forgeblocks.com/');

await page.getByLabel('User Name').fill(pingAmUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(pingAmPassword);
await page.getByRole('button', { name: 'Next' }).click();

await page.waitForURL('http://localhost:8443/ping-am/**');
expect(page.url()).toContain('code');
expect(page.url()).toContain('state');
await expect(page.locator('#accessToken-0')).not.toBeEmpty();
await expect(page.locator('#accessToken-0')).not.toHaveText('undefined');

await page.evaluate(() => window.localStorage.clear());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This too. Can we just call user info without logging in first to then clear the tokens?

}),
),
).pipe(
Micro.map(({ error }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we discuss this as a team at a later time? I think I understand your intention here, but I'd like to be sure we test this out together and ensure we are on the same page with the desired technical outcome. I'd encourage us to go ahead and merge this as it's consistent with the existing pattern.

After the discussion, we can update both to be consistent.

},
}),
userInfo: builder.mutation<TokenExchangeResponse, { accessToken: string; endpoint: string }>({
userInfo: builder.mutation<unknown, { accessToken: string; endpoint: string }>({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the original type was an error, but I don't want to use unknown. The structure of the response is known, though much of it is optional. It's defined by the spec. Let's provide a more explicitly structured object, instead of unknown.

}),
// Delete local token and return combined results
Micro.flatMap((revokeResponse) =>
Micro.promise(() => storageClient.remove()).pipe(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good.

Semantically, it may "feel" cleaner if we wrap both effects in a pipe, but not a big deal.

Alternative being:

pipe(
    Micro.promise(() => storageClient.remove()),
    Micro.map((deleteResponse) => ({ revokeResponse, deleteResponse }))
 )

This is entirely subjective so i'll leave it to you what you feel reads better they are functionally equivalent if i have my mental model correct.

@ancheetah ancheetah force-pushed the SDKS-4348-token-revoke branch 2 times, most recently from 24ddb2e to 45c2862 Compare August 27, 2025 17:55
@ancheetah ancheetah requested review from cerebrl and ryanbas21 August 27, 2025 18:00
Micro.flatMap((revokeResponse) =>
Micro.promise(() => storageClient.remove()).pipe(
Micro.map((deleteResponse) => ({
isError: !!(revokeResponse && 'error' in revokeResponse),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would personally prefer just using error to keep it consistent. It could contain a value of "Inner request error" or, even more, have an addition of errorKey with a value of the request that contains the error?

So, I'd suggest something like this:

{
  error: "Inner request error",
  errorKey: "revokeResponse",
  revokeResponse: { /** inner response error **/ },
  deleteResponse: null,
}

Thoughts?

@ancheetah ancheetah force-pushed the SDKS-4348-token-revoke branch from 45c2862 to 3f7416a Compare September 4, 2025 00:33
@ancheetah ancheetah requested a review from cerebrl September 4, 2025 00:40
Comment on lines 56 to 67
let errorKey;
if (sessionResponse && 'error' in sessionResponse) {
errorKey = 'sessionResponse';
} else if (revokeResponse && 'error' in revokeResponse) {
errorKey = 'revokeResponse';
} else if (
deleteResponse &&
typeof deleteResponse === 'object' &&
'error' in deleteResponse
) {
errorKey = 'deleteResponse';
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I'm the one who suggested this, but I'm now thinking this errorKey could be misleading. If more than one response has an error, we will only share the first in this key. We could convert this string into an array and iterate over the errors, but at that point, I think we lose the value.

So, I'm thinking we just remove the errorKey, but leave error. Thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah let's just remove errorKey

await page.getByRole('button', { name: 'Next' }).click();

await page.waitForURL('http://localhost:8443/ping-am/**');
await page.waitForURL('http://localhost:8443/ping-am/**', { waitUntil: 'networkidle' });
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think networkidle is an anti-pattern or discouraged.

Copy link
Collaborator

@ryanbas21 ryanbas21 Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'networkidle' - DISCOURAGED consider operation to be finished when there are no network connections for at least 500 ms. Don't use this method for testing, rely on web assertions to assess readiness instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This true but we use it a lot in our tests rights now. We have a ticket to do a bunch of Playwright updates later on.
https://pingidentity.atlassian.net/browse/SDKS-4331

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do use networkidle in more places, let's just note it in that Playwright improvement story and leave it for then. Ultimately, I'd like to create a utility function that does this since "clicking a button and waiting for a response" as it is a super common pattern. That was the purpose of the old util file, and I'd like to keep to that goal.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the ticket

@ancheetah ancheetah force-pushed the SDKS-4348-token-revoke branch from 3f7416a to c48d9c8 Compare September 5, 2025 21:11
@ancheetah ancheetah merged commit 1416ec3 into main Sep 9, 2025
3 checks passed
@ancheetah ancheetah deleted the SDKS-4348-token-revoke branch September 9, 2025 17:30
@ryanbas21 ryanbas21 mentioned this pull request Sep 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants