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:
committed by
Jonathan Roebuck
parent
3532be4763
commit
208cf5f922
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user