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:
@@ -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();
|
||||
},
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
type OAuthAuthenticator,
|
||||
type OAuthAuthenticatorAuthenticateInput,
|
||||
type OAuthAuthenticatorLogoutInput,
|
||||
type OAuthAuthenticatorLogoutResult,
|
||||
type OAuthAuthenticatorRefreshInput,
|
||||
type OAuthAuthenticatorResult,
|
||||
type OAuthAuthenticatorScopeOptions,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user