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
@@ -301,4 +301,16 @@ describe('DefaultAuthConnector', () => {
await connector.removeSession();
// Should complete without error — non-JSON responses are ignored
});
it('should ignore logoutUrl with non-HTTPS protocol', async () => {
server.use(
rest.post('*', (_req, res, ctx) =>
res(ctx.json({ logoutUrl: 'http://evil.com/steal' })),
),
);
const connector = new DefaultAuthConnector(defaultOptions);
await connector.removeSession();
// Should complete normally without redirecting - http:// is rejected
});
});
@@ -33,6 +33,11 @@ export interface Config {
connection?: string;
connectionScope?: string;
organization?: string;
/**
* Whether to perform federated logout, clearing both the Auth0
* session and any upstream IdP session. Defaults to false.
*/
federated?: boolean;
sessionDuration?: HumanDuration | string;
};
};
@@ -17,6 +17,7 @@ export const auth0Authenticator: OAuthAuthenticator<
connectionScope: string | undefined;
domain: string;
clientID: string;
federated: boolean;
},
PassportProfile
>;
@@ -85,7 +85,16 @@ export const auth0Authenticator = createOAuthAuthenticator({
},
),
);
return { helper, audience, connection, connectionScope, domain, clientID };
const federated = config.getOptionalBoolean('federated') ?? false;
return {
helper,
audience,
connection,
connectionScope,
domain,
clientID,
federated,
};
},
async start(
@@ -116,12 +125,16 @@ export const auth0Authenticator = createOAuthAuthenticator({
return helper.refresh(input);
},
async logout(input, { domain, clientID }) {
const origin = input.req.get('origin') ?? '';
async logout(input, { domain, clientID, federated }) {
const origin = input.req.get('origin');
const federatedParam = federated ? 'federated&' : '';
const returnToParam = origin
? `&returnTo=${encodeURIComponent(origin)}`
: '';
return {
logoutUrl: `https://${domain}/v2/logout?federated&client_id=${encodeURIComponent(
logoutUrl: `https://${domain}/v2/logout?${federatedParam}client_id=${encodeURIComponent(
clientID,
)}&returnTo=${encodeURIComponent(origin)}`,
)}${returnToParam}`,
};
},
});
@@ -181,7 +181,7 @@ describe('authModuleAuth0Provider', () => {
);
});
it('should return Auth0 logout URL on logout', async () => {
it('should return Auth0 logout URL without federated param by default', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
@@ -210,6 +210,51 @@ describe('authModuleAuth0Provider', () => {
],
});
const res = await request(server)
.post('/api/auth/auth0/logout')
.query({ env: 'development' })
.set('X-Requested-With', 'XMLHttpRequest')
.set('Origin', 'http://localhost:3000');
expect(res.status).toBe(200);
expect(res.body.logoutUrl).toContain('test.eu.auth0.com/v2/logout');
expect(res.body.logoutUrl).not.toContain('federated');
expect(res.body.logoutUrl).toContain('client_id=test-client-id');
expect(res.body.logoutUrl).toContain(
`returnTo=${encodeURIComponent('http://localhost:3000')}`,
);
});
it('should include federated param when federated is true', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleAuth0Provider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
auth0: {
development: {
clientId: 'test-client-id',
clientSecret: 'clientSecret',
domain: 'test.eu.auth0.com',
federated: true,
},
},
},
session: {
secret: 'secret',
},
},
},
}),
],
});
const res = await request(server)
.post('/api/auth/auth0/logout')
.query({ env: 'development' })
@@ -224,4 +269,44 @@ describe('authModuleAuth0Provider', () => {
`returnTo=${encodeURIComponent('http://localhost:3000')}`,
);
});
it('should fall back to app.baseUrl when origin header is missing', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleAuth0Provider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
auth0: {
development: {
clientId: 'test-client-id',
clientSecret: 'clientSecret',
domain: 'test.eu.auth0.com',
},
},
},
session: {
secret: 'secret',
},
},
},
}),
],
});
const res = await request(server)
.post('/api/auth/auth0/logout')
.query({ env: 'development' })
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body.logoutUrl).toContain(
`returnTo=${encodeURIComponent('http://localhost:3000')}`,
);
});
});
@@ -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(