Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions examples/example-express-web/src/auth0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,10 @@ export function auth0(options: Auth0ExpressOptions) {
response.redirect(logoutUrl.href);
});

router.post('/auth/revoke', async (request: Request, response: Response) => {
await request.auth0Client.revokeRefreshToken({}, { request, response });
response.redirect('/private');
});

return router;
}
6 changes: 5 additions & 1 deletion examples/example-express-web/views/private.ejs
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
This is a private page.
This is a private page.

<form method="POST" action="/auth/revoke" class="mt-3">
<button type="submit" class="btn btn-warning">Revoke Refresh Token</button>
</form>
90 changes: 90 additions & 0 deletions packages/auth0-auth-js/src/auth-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3992,3 +3992,93 @@ test('database.changePassword request carries the Auth0-Client telemetry header'
await c.database.changePassword({ email: 'a@b.com', connection: 'db' });
expect(headers?.get('Auth0-Client')).toBeTruthy();
});

describe('revokeToken', () => {
Comment thread
jd3vi1 marked this conversation as resolved.
const revocationEndpoint = `https://${domain}/oauth/revoke`;

const setupRevocationHandlers = (handler: Parameters<typeof http.post>[1]) => {
server.use(
http.get(`https://${domain}/.well-known/openid-configuration`, () =>
HttpResponse.json({ ...buildOpenIdConfiguration(domain), revocation_endpoint: revocationEndpoint })
),
http.post(revocationEndpoint, handler)
);
};

const makeClient = () =>
new AuthClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
discoveryCache: { ttl: 0 },
});

test('should successfully revoke a token', async () => {
let capturedToken: string | null = null;
let capturedHint: string | null = null;
setupRevocationHandlers(async ({ request }) => {
const body = await request.formData();
capturedToken = body.get('token') as string;
capturedHint = body.get('token_type_hint') as string;
return new HttpResponse(null, { status: 200 });
});

await expect(
makeClient().revokeToken({ token: '<refresh_token>', tokenTypeHint: 'refresh_token' })
).resolves.toBeUndefined();
expect(capturedToken).toBe('<refresh_token>');
expect(capturedHint).toBe('refresh_token');
});

test('should revoke a token without tokenTypeHint', async () => {
let capturedHint: FormDataEntryValue | null = null;
setupRevocationHandlers(async ({ request }) => {
const body = await request.formData();
capturedHint = body.get('token_type_hint');
return new HttpResponse(null, { status: 200 });
});

await expect(
makeClient().revokeToken({ token: '<refresh_token>' })
).resolves.toBeUndefined();
expect(capturedHint).toBeNull();
});

test('should throw TokenRevocationError when revocation fails', async () => {
setupRevocationHandlers(() =>
HttpResponse.json(
{ error: '<error_code>', error_description: '<error_description>' },
{ status: 400 }
)
);

await expect(
makeClient().revokeToken({ token: '<invalid_token>' })
).rejects.toThrowError(
expect.objectContaining({
code: 'token_revocation_error',
message: 'An error occurred while trying to revoke the token.',
cause: expect.objectContaining({
error: '<error_code>',
error_description: '<error_description>',
}),
})
);
});

test('should send client credentials on the revocation request', async () => {
let capturedClientId: string | null = null;
let capturedClientSecret: string | null = null;
setupRevocationHandlers(async ({ request }) => {
const body = await request.formData();
capturedClientId = body.get('client_id') as string;
capturedClientSecret = body.get('client_secret') as string;
return new HttpResponse(null, { status: 200 });
});

await makeClient().revokeToken({ token: '<refresh_token>', tokenTypeHint: 'refresh_token' });

expect(capturedClientId).toBe('<client_id>');
expect(capturedClientSecret).toBe('<client_secret>');
});
});
23 changes: 23 additions & 0 deletions packages/auth0-auth-js/src/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BuildLinkUserUrlError,
BuildUnlinkUserUrlError,
TokenExchangeError,
TokenRevocationError,
MissingClientAuthError,
NotSupportedError,
NotSupportedErrorCode,
Expand Down Expand Up @@ -46,6 +47,7 @@ import {
TokenByPasswordlessEmailOptions,
TokenByPasswordlessSmsOptions,
TokenByRefreshTokenOptions,
RevokeTokenOptions,
TokenForConnectionOptions,
TokenResponse,
ActClaim,
Expand Down Expand Up @@ -1113,6 +1115,27 @@ export class AuthClient {
}
}

/**
* Revokes a token at the Auth0 /oauth/revoke endpoint.
*
* @throws {TokenRevocationError} If the revocation request fails.
*/
public async revokeToken(options: RevokeTokenOptions): Promise<void> {
const { configuration } = await this.#discover();
const params: Record<string, string> = {};
if (options.tokenTypeHint) {
params['token_type_hint'] = options.tokenTypeHint;
}
try {
await client.tokenRevocation(configuration, options.token, params);
} catch (e) {
throw new TokenRevocationError(
'An error occurred while trying to revoke the token.',
toOAuth2Error(e)
);
}
}

/**
* Retrieves a token using Resource Owner Password Grant.
* @param options Options for authenticating with username and password.
Expand Down
10 changes: 10 additions & 0 deletions packages/auth0-auth-js/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ export class TokenExchangeError extends ApiError {
}
}

/**
* Error thrown when revoking a token fails.
*/
export class TokenRevocationError extends ApiError {
constructor(message: string, cause?: OAuth2Error) {
super('token_revocation_error', message, cause);
this.name = 'TokenRevocationError';
}
}

/**
* Error thrown when verifying the logout token.
*/
Expand Down
7 changes: 7 additions & 0 deletions packages/auth0-auth-js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,13 @@ export interface TokenVaultExchangeOptions {
extra?: Record<string, string | string[]>;
}

export interface RevokeTokenOptions {
/** The token to revoke. */
token: string;
/** Hint about the token type. Per RFC 7009. */
tokenTypeHint?: 'refresh_token' | 'access_token';
}

export interface BuildLogoutUrlOptions {
/**
* The URL to which the user should be redirected after the logout.
Expand Down
43 changes: 43 additions & 0 deletions packages/auth0-server-js/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
- [Passing `StoreOptions`](#passing-storeoptions-9)
- [Retrieving an Access Token for a Connection](#retrieving-an-access-token-for-a-connection)
- [Passing `StoreOptions`](#passing-storeoptions-10)
- [Revoking a Refresh Token](#revoking-a-refresh-token)
- [Revoking the session token](#revoking-the-session-token)
- [Revoking an explicit token](#revoking-an-explicit-token)
- [Revoking on logout](#revoking-on-logout)
- [Logout](#logout)
- [Passing the `returnTo` parameter](#passing-the-returnto-parameter)
- [Passing `StoreOptions`](#passing-storeoptions-11)
Expand Down Expand Up @@ -1473,6 +1477,45 @@ Once an enterprise connection has the option enabled, `getSession()` / `getUser(
- The connection must be an `okta` or `oidc` enterprise connection with `id_token_session_expiry_supported: true` (Dashboard toggle "Use ID Token for Session Expiry", Management API, or Terraform).
- Authorization Code flow.

## Revoking a Refresh Token

Revoking a refresh token invalidates it at Auth0 so it can no longer be used to obtain new access tokens.
This is useful when implementing secure logout flows or when a user's session needs to be forcibly terminated.

Revocation requires the application to have been granted `offline_access` scope so Auth0 issues a refresh token, and the target API must have **Allow Offline Access** enabled.

> **Note:** Revocation does not affect access tokens that have already been issued. They remain valid until their expiry. For immediate session termination, combine revocation with `logout()`.

### Revoking the session token

When called without arguments, `revokeRefreshToken()` reads the refresh token directly from the current session:

```ts
await serverClient.revokeRefreshToken();
```

If no session exists or the session has no refresh token, a `MissingSessionError` is thrown.

### Revoking an explicit token

A specific token can be passed via `options.token`, bypassing the session lookup:

```ts
await serverClient.revokeRefreshToken({ token: '<refresh_token>' });
```

### Revoking on logout

`logout()` automatically revokes the session's refresh token before clearing the local session.
Revocation is best-effort: if it fails for any reason (network error, token already revoked, misconfiguration), logout still proceeds. In resolver mode, both revocation and local session deletion only occur when the stored session domain matches the resolved domain — if they differ, the session belongs to a different tenant and is left untouched.

```ts
const logoutUrl = await serverClient.logout({
returnTo: 'http://localhost:3000',
});
// Redirect user to logoutUrl
```

## Logout

Logging out ensures the stored tokens and user information are removed, and that the user is no longer considered logged-in by the SDK.
Expand Down
2 changes: 1 addition & 1 deletion packages/auth0-server-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export { ServerClient } from './server-client.js';
export { AbstractStateStore } from './store/abstract-state-store.js';
export { AbstractTransactionStore } from './store/abstract-transaction-store.js';
export type { TokenResponse, ActClaim } from '@auth0/auth0-auth-js';
export { TokenExchangeError, MissingClientAuthError, OrganizationValidationError } from '@auth0/auth0-auth-js';
export { TokenExchangeError, TokenRevocationError, MissingClientAuthError, OrganizationValidationError } from '@auth0/auth0-auth-js';

export type { CookieHandler, CookieSerializeOptions } from './store/cookie-handler.js';
export { CookieTransactionStore } from './store/cookie-transaction-store.js';
Expand Down
Loading
Loading