Merge pull request #33703 from backstage/feat/auth0-federated-logout

feat(auth): support provider logout redirects, implement Auth0 federated logout
This commit is contained in:
Patrik Oldsberg
2026-04-01 15:27:27 +02:00
committed by GitHub
14 changed files with 393 additions and 6 deletions
+9 -1
View File
@@ -293,7 +293,10 @@ export interface OAuthAuthenticator<TContext, TProfile> {
// (undocumented)
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
// (undocumented)
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
logout?(
input: OAuthAuthenticatorLogoutInput,
ctx: TContext,
): Promise<void | OAuthAuthenticatorLogoutResult>;
// (undocumented)
refresh(
input: OAuthAuthenticatorRefreshInput,
@@ -329,6 +332,11 @@ export interface OAuthAuthenticatorLogoutInput {
req: Request_2;
}
// @public (undocumented)
export interface OAuthAuthenticatorLogoutResult {
logoutUrl?: string;
}
// @public (undocumented)
export interface OAuthAuthenticatorRefreshInput {
// (undocumented)
@@ -1264,6 +1264,135 @@ describe('createOAuthRouteHandlers', () => {
});
});
it('should return logoutUrl as JSON when authenticator provides one', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce({
logoutUrl: 'https://example.auth0.com/v2/logout?federated',
});
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
agent.jar.setCookie(
'my-provider-refresh-token=my-refresh-token',
'127.0.0.1',
'/my-provider',
);
const res = await agent
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
expect(res.body).toEqual({
logoutUrl: 'https://example.auth0.com/v2/logout?federated',
});
// Cookie should still be cleared even when logoutUrl is returned
expect(getRefreshTokenCookie(agent)).toBeUndefined();
});
it('should return empty body when authenticator logout returns void', async () => {
(mockAuthenticator.logout as jest.Mock).mockResolvedValueOnce(undefined);
const agent = request.agent(
wrapInApp(createOAuthRouteHandlers(baseConfig)),
);
const res = await agent
.post('/my-provider/logout')
.set('X-Requested-With', 'XMLHttpRequest');
expect(res.status).toBe(200);
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)
@@ -39,7 +39,11 @@ import {
ProfileTransform,
SignInResolver,
} from '../types';
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
import {
OAuthAuthenticator,
OAuthAuthenticatorLogoutResult,
OAuthAuthenticatorResult,
} from './types';
import { Config, readDurationFromConfig } from '@backstage/config';
import { CookieScopeManager } from './CookieScopeManager';
@@ -280,17 +284,41 @@ export function createOAuthRouteHandlers<TProfile>(
throw new AuthenticationError('Invalid X-Requested-With header');
}
const origin = req.get('origin');
if (origin && !isOriginAllowed(origin)) {
throw new NotAllowedError(`Origin '${origin}' is not allowed`);
}
let logoutResult: void | OAuthAuthenticatorLogoutResult = undefined;
if (authenticator.logout) {
const refreshToken = cookieManager.getRefreshToken(req);
await authenticator.logout({ req, refreshToken }, authenticatorCtx);
logoutResult = await authenticator.logout(
{ req, refreshToken },
authenticatorCtx,
);
}
// remove refresh token cookie if it is set
cookieManager.removeRefreshToken(res, req.get('origin'));
cookieManager.removeRefreshToken(res, origin);
// remove persisted scopes
await scopeManager.clear(req);
if (logoutResult?.logoutUrl) {
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();
},
+1
View File
@@ -37,6 +37,7 @@ export {
type OAuthAuthenticator,
type OAuthAuthenticatorAuthenticateInput,
type OAuthAuthenticatorLogoutInput,
type OAuthAuthenticatorLogoutResult,
type OAuthAuthenticatorRefreshInput,
type OAuthAuthenticatorResult,
type OAuthAuthenticatorScopeOptions,
+14 -1
View File
@@ -76,6 +76,16 @@ export interface OAuthAuthenticatorLogoutInput {
req: Request;
}
/** @public */
export interface OAuthAuthenticatorLogoutResult {
/**
* If set, the frontend will redirect the browser to this URL after clearing
* the Backstage session. Use this to terminate provider-side sessions (e.g.
* Auth0's `/v2/logout` endpoint).
*/
logoutUrl?: string;
}
/** @public */
export interface OAuthAuthenticatorResult<TProfile> {
fullProfile: TProfile;
@@ -101,7 +111,10 @@ export interface OAuthAuthenticator<TContext, TProfile> {
input: OAuthAuthenticatorRefreshInput,
ctx: TContext,
): Promise<OAuthAuthenticatorResult<TProfile>>;
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
logout?(
input: OAuthAuthenticatorLogoutInput,
ctx: TContext,
): Promise<void | OAuthAuthenticatorLogoutResult>;
}
/** @public */