fix(auth): add security hardening and federated config for Auth0 logout

Add server-side URL validation for logoutUrl (HTTPS + localhost only),
origin validation on the logout endpoint, and a configurable `federated`
option (default false) for Auth0 provider logout. Includes comprehensive
test coverage for all security controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Jack Palmer <jackpalmer@spotify.com>
This commit is contained in:
Jack Palmer
2026-04-01 11:19:31 +01:00
committed by Jonathan Roebuck
parent 3532be4763
commit 208cf5f922
7 changed files with 222 additions and 9 deletions
@@ -1307,6 +1307,92 @@ describe('createOAuthRouteHandlers', () => {
expect(res.body).toEqual({});
});
it('should reject logout from disallowed origin', async () => {
const app = wrapInApp(
createOAuthRouteHandlers({
...baseConfig,
isOriginAllowed: origin => origin === 'http://allowed.example.com',
}),
);
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest')
.set('Origin', 'https://evil.com');
expect(res.status).toBe(403);
expect(res.body).toMatchObject({
error: {
name: 'NotAllowedError',
message: "Origin 'https://evil.com' is not allowed",
},
});
});
it('should strip logoutUrl with non-HTTPS protocol', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'http://evil.com/redirect',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it('should accept logoutUrl with HTTPS protocol', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'https://auth.example.com/v2/logout',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({
logoutUrl: 'https://auth.example.com/v2/logout',
});
});
it('should accept logoutUrl targeting localhost', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'http://localhost:3000/logout-callback',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({
logoutUrl: 'http://localhost:3000/logout-callback',
});
});
it('should handle malformed logoutUrl gracefully', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'not-a-valid-url',
});
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it('should set error search param and redirect on caught error', async () => {
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
const res = await request(app)
@@ -305,10 +305,21 @@ export function createOAuthRouteHandlers<TProfile>(
await scopeManager.clear(req);
if (logoutResult?.logoutUrl) {
res.status(200).json({ logoutUrl: logoutResult.logoutUrl });
} else {
res.status(200).end();
try {
const logoutUrl = new URL(logoutResult.logoutUrl);
if (
logoutUrl.protocol === 'https:' ||
logoutUrl.hostname === 'localhost'
) {
res.status(200).json({ logoutUrl: logoutResult.logoutUrl });
return;
}
} catch {
// Malformed URL — fall through to empty response
}
}
res.status(200).end();
},
async refresh(