Skip to content

Commit 3fbca81

Browse files
committed
refac: improve logout behavior with revokeRefreshToken handling in resolver mode
1 parent 82830df commit 3fbca81

3 files changed

Lines changed: 78 additions & 30 deletions

File tree

packages/auth0-server-js/EXAMPLES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1507,7 +1507,7 @@ await serverClient.revokeRefreshToken({ token: '<refresh_token>' });
15071507
### Revoking on logout
15081508
15091509
Pass `revokeRefreshToken: true` to `logout()` to revoke the session's refresh token as part of the logout flow.
1510-
Revocation is best-effort: if it fails for any reason (network error, token already revoked, misconfiguration), logout still proceeds. In resolver mode, local session state is cleared only when the stored session domain matches the resolved domain.
1510+
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.
15111511
15121512
```ts
15131513
const logoutUrl = await serverClient.logout({

packages/auth0-server-js/src/server-client.spec.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7110,27 +7110,67 @@ describe('logout with revokeRefreshToken option', () => {
71107110
expect(mockStateStore.delete).toHaveBeenCalled();
71117111
});
71127112

7113-
test('should revoke using session domain in resolver mode when called via logout()', async () => {
7114-
const sessionDomain = 'session.local';
7115-
const resolverDomain = 'resolver.local';
7116-
const sessionRevocationEndpoint = `https://${sessionDomain}/oauth/revoke`;
7113+
test('should revoke and delete session in resolver mode when session domain matches resolved domain', async () => {
7114+
const matchedDomain = 'matched.local';
7115+
const revocationEndpointMatched = `https://${matchedDomain}/oauth/revoke`;
71177116

7118-
let sessionRevokeCalled = false;
7117+
let revokeCalled = false;
71197118
server.use(
7120-
http.get(`https://${sessionDomain}/.well-known/openid-configuration`, () =>
7119+
http.get(`https://${matchedDomain}/.well-known/openid-configuration`, () =>
71217120
HttpResponse.json({
7122-
issuer: `https://${sessionDomain}/`,
7123-
authorization_endpoint: `https://${sessionDomain}/authorize`,
7124-
token_endpoint: `https://${sessionDomain}/token`,
7125-
end_session_endpoint: `https://${sessionDomain}/logout`,
7126-
revocation_endpoint: sessionRevocationEndpoint,
7127-
jwks_uri: `https://${sessionDomain}/.well-known/jwks.json`,
7121+
issuer: `https://${matchedDomain}/`,
7122+
authorization_endpoint: `https://${matchedDomain}/authorize`,
7123+
token_endpoint: `https://${matchedDomain}/token`,
7124+
end_session_endpoint: `https://${matchedDomain}/logout`,
7125+
revocation_endpoint: revocationEndpointMatched,
7126+
jwks_uri: `https://${matchedDomain}/.well-known/jwks.json`,
71287127
})
71297128
),
7130-
http.post(sessionRevocationEndpoint, () => {
7131-
sessionRevokeCalled = true;
7129+
http.post(revocationEndpointMatched, () => {
7130+
revokeCalled = true;
71327131
return new HttpResponse(null, { status: 200 });
7133-
}),
7132+
})
7133+
);
7134+
7135+
const mockStateStore = {
7136+
get: vi.fn(),
7137+
set: vi.fn(),
7138+
delete: vi.fn(),
7139+
deleteByLogoutToken: vi.fn(),
7140+
};
7141+
7142+
const stateData: StateData = {
7143+
user: { sub: '<sub>' },
7144+
idToken: '<id_token>',
7145+
refreshToken: '<refresh_token>',
7146+
tokenSets: [],
7147+
domain: matchedDomain,
7148+
internal: { sid: '<sid>', createdAt: Date.now() },
7149+
};
7150+
7151+
mockStateStore.get.mockResolvedValue(stateData);
7152+
7153+
const serverClient = new ServerClient({
7154+
domain: vi.fn().mockResolvedValue(matchedDomain),
7155+
clientId: '<client_id>',
7156+
clientSecret: '<client_secret>',
7157+
discoveryCache: { ttl: 0 },
7158+
transactionStore: { get: vi.fn(), set: vi.fn(), delete: vi.fn() },
7159+
stateStore: mockStateStore,
7160+
});
7161+
7162+
const url = await serverClient.logout({ returnTo: '/after-logout', revokeRefreshToken: true });
7163+
7164+
expect(revokeCalled).toBe(true);
7165+
expect(mockStateStore.delete).toHaveBeenCalled();
7166+
expect(url).toBeDefined();
7167+
});
7168+
7169+
test('should skip revocation and session deletion in resolver mode when session domain does not match resolved domain', async () => {
7170+
const sessionDomain = 'session.local';
7171+
const resolverDomain = 'resolver.local';
7172+
7173+
server.use(
71347174
http.get(`https://${resolverDomain}/.well-known/openid-configuration`, () =>
71357175
HttpResponse.json({
71367176
issuer: `https://${resolverDomain}/`,
@@ -7143,8 +7183,6 @@ describe('logout with revokeRefreshToken option', () => {
71437183
)
71447184
);
71457185

7146-
const domainResolver = vi.fn().mockResolvedValue(resolverDomain);
7147-
71487186
const mockStateStore = {
71497187
get: vi.fn(),
71507188
set: vi.fn(),
@@ -7164,7 +7202,7 @@ describe('logout with revokeRefreshToken option', () => {
71647202
mockStateStore.get.mockResolvedValue(stateData);
71657203

71667204
const serverClient = new ServerClient({
7167-
domain: domainResolver,
7205+
domain: vi.fn().mockResolvedValue(resolverDomain),
71687206
clientId: '<client_id>',
71697207
clientSecret: '<client_secret>',
71707208
discoveryCache: { ttl: 0 },
@@ -7174,7 +7212,7 @@ describe('logout with revokeRefreshToken option', () => {
71747212

71757213
const url = await serverClient.logout({ returnTo: '/after-logout', revokeRefreshToken: true });
71767214

7177-
expect(sessionRevokeCalled).toBe(true);
7215+
expect(mockStateStore.delete).not.toHaveBeenCalled();
71787216
expect(url).toBeDefined();
71797217
});
71807218
});

packages/auth0-server-js/src/server-client.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,30 +1128,40 @@ export class ServerClient<TStoreOptions = unknown> {
11281128
* @returns {URL}
11291129
*/
11301130
public async logout(options: LogoutOptions, storeOptions?: TStoreOptions) {
1131-
if (options.revokeRefreshToken) {
1132-
try {
1133-
await this.revokeRefreshToken({}, storeOptions);
1134-
} catch {
1135-
// best-effort: revocation failure must not block logout
1136-
}
1137-
}
1138-
11391131
if (!this.#isResolverMode()) {
1132+
if (options.revokeRefreshToken) {
1133+
try {
1134+
await this.revokeRefreshToken({}, storeOptions);
1135+
} catch (e) {
1136+
// best-effort: revocation failure must not block logout
1137+
console.warn('revokeRefreshToken failed during logout (swallowed):', e);
1138+
}
1139+
}
11401140
await this.#stateStore.delete(this.#stateStoreIdentifier, storeOptions);
11411141
return this.authClient.buildLogoutUrl(options);
11421142
}
11431143

11441144
const resolvedDomain = await this.#resolveDomain(storeOptions);
11451145
const authClient = this.#getAuthClient(resolvedDomain);
11461146
const stateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions);
1147-
const sessionDomain = stateData ? this.#getSessionDomain(stateData) : undefined;
11481147

11491148
if (!stateData) {
11501149
// No local session, still return a logout URL for the current domain.
11511150
return authClient.buildLogoutUrl(options);
11521151
}
11531152

1154-
if (sessionDomain && sessionDomain === resolvedDomain) {
1153+
const sessionDomain = this.#getSessionDomain(stateData);
1154+
const domainMatches = sessionDomain === resolvedDomain;
1155+
1156+
if (domainMatches) {
1157+
if (options.revokeRefreshToken) {
1158+
try {
1159+
await this.revokeRefreshToken({}, storeOptions);
1160+
} catch (e) {
1161+
// best-effort: revocation failure must not block logout
1162+
console.warn('revokeRefreshToken failed during logout (swallowed):', e);
1163+
}
1164+
}
11551165
await this.#stateStore.delete(this.#stateStoreIdentifier, storeOptions);
11561166
}
11571167

0 commit comments

Comments
 (0)