Skip to content

Commit 2871fbf

Browse files
arthur791004claude
andauthored
Add /logout route that clears site data via Clear-Site-Data (#112567)
Register a server-side /logout route on both the classic Calypso and Dashboard hostnames. It returns Clear-Site-Data for the current origin, best-effort clears the counterpart origin through a hidden iframe, then redirects onward. The redirect destination honors a `redirect_to` query param, but only for same-origin destinations (returned as a relative path) to avoid an open redirect. It defaults to /log-in. Nothing routes through /logout yet; wiring up the existing logout flows is a follow-up. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 13b860c commit 2871fbf

2 files changed

Lines changed: 160 additions & 1 deletion

File tree

client/document/logout.jsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
function logoutFn( { redirectTo } ) {
2+
function done() {
3+
window.location.replace( redirectTo );
4+
}
5+
6+
const iframe = document.getElementById( 'logout-clear-frame' );
7+
8+
if ( ! iframe ) {
9+
done();
10+
return;
11+
}
12+
13+
// Redirect once the counterpart origin has been cleared, with a fallback in
14+
// case its iframe never fires `load` (e.g. blocked by the browser).
15+
const fallback = window.setTimeout( done, 2000 );
16+
iframe.addEventListener( 'load', function () {
17+
window.clearTimeout( fallback );
18+
done();
19+
} );
20+
}
21+
22+
function Logout( { redirectTo = '/log-in', iframeSrc = null, embed = false } ) {
23+
// The embed variant is loaded inside the counterpart origin's iframe. Its only
24+
// job is to carry the `Clear-Site-Data` response header, so it renders nothing.
25+
if ( embed ) {
26+
return (
27+
<html lang="en">
28+
<body />
29+
</html>
30+
);
31+
}
32+
33+
return (
34+
<html lang="en">
35+
<body>
36+
{ /* eslint-disable react/no-danger */ }
37+
<style
38+
dangerouslySetInnerHTML={ {
39+
__html: `
40+
body {
41+
margin: 0;
42+
min-height: 100vh;
43+
display: flex;
44+
align-items: center;
45+
justify-content: center;
46+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
47+
}
48+
`,
49+
} }
50+
/>
51+
<p>Logging out…</p>
52+
{ iframeSrc && (
53+
<iframe
54+
id="logout-clear-frame"
55+
title="Signing out"
56+
src={ iframeSrc }
57+
style={ { display: 'none' } }
58+
/>
59+
) }
60+
<script
61+
dangerouslySetInnerHTML={ {
62+
__html: `
63+
const logoutFn = ${ logoutFn.toString() };
64+
65+
logoutFn( { redirectTo: "${ encodeURI( redirectTo ) }" } );
66+
`,
67+
} }
68+
/>
69+
{ /* eslint-enable react/no-danger */ }
70+
</body>
71+
</html>
72+
);
73+
}
74+
75+
export default Logout;

client/server/pages/index.js

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,17 @@ import {
2828
CIAB_DASHBOARD_SECTION_DEFINITION,
2929
CIAB_DASHBOARD_SECTION_PATHS,
3030
} from 'calypso/dashboard/app-ciab/section';
31-
import { isAllowedDotcomDashboardHostname } from 'calypso/dashboard/app-dotcom/routing';
31+
import {
32+
buildDotcomDashboardLink,
33+
isAllowedDotcomDashboardHostname,
34+
} from 'calypso/dashboard/app-dotcom/routing';
3235
import {
3336
DOTCOM_DASHBOARD_SECTION_DEFINITION,
3437
DOTCOM_DASHBOARD_SECTION_PATHS,
3538
} from 'calypso/dashboard/app-dotcom/section';
3639
import { A4A_SIGNUP_PATHS } from 'calypso/dashboard/section';
3740
import isDashboardEnv from 'calypso/dashboard/utils/is-dashboard-env';
41+
import { wpcomLink } from 'calypso/dashboard/utils/link';
3842
import wooDnaConfig from 'calypso/jetpack-connect/woo-dna-config';
3943
import { STEPPER_SECTION_DEFINITION } from 'calypso/landing/stepper/section';
4044
import { SUBSCRIPTIONS_SECTION_DEFINITION } from 'calypso/landing/subscriptions/section';
@@ -1212,6 +1216,62 @@ function wpcomPages( app ) {
12121216
} );
12131217
}
12141218

1219+
/**
1220+
* Resolve the counterpart origin's `/logout` URL for the cross-origin data clear.
1221+
*
1222+
* `Clear-Site-Data` for "storage"/"cache" is scoped to the origin that returns
1223+
* it, and the classic Calypso app and the Dashboard are served from separate
1224+
* origins (e.g. wordpress.com vs. my.wordpress.com). To clear both, `/logout`
1225+
* embeds the counterpart origin's `/logout?embed=1` in a hidden iframe. Returns
1226+
* `null` when there is no distinct counterpart (e.g. single-origin calypso.live).
1227+
*/
1228+
function getCounterpartLogoutUrl( req ) {
1229+
if ( req.hostname.endsWith( '.calypso.live' ) ) {
1230+
return null;
1231+
}
1232+
1233+
if ( isAllowedDotcomDashboardHostname( req.hostname ) ) {
1234+
// On the Dashboard host, the classic Calypso origin is the counterpart.
1235+
// `wpcomLink` resolves it from the `wpcom_url` config (wordpress.com in
1236+
// production, the local Calypso dev server in development).
1237+
return wpcomLink( '/logout?embed=1' );
1238+
}
1239+
1240+
if ( [ 'wordpress.com', 'calypso.localhost' ].includes( req.hostname ) ) {
1241+
return buildDotcomDashboardLink( '/logout?embed=1' );
1242+
}
1243+
1244+
return null;
1245+
}
1246+
1247+
/**
1248+
* Resolve the destination `/logout` sends the user to once data is cleared.
1249+
*
1250+
* Honors a `redirect_to` query param, but only for same-origin destinations —
1251+
* returned as a relative path so the client-side redirect can never leave this
1252+
* origin (guards against open redirects). Anything else falls back to `/log-in`.
1253+
*/
1254+
function getLogoutRedirectTo( req ) {
1255+
const target = req.query.redirect_to;
1256+
if ( typeof target !== 'string' || ! target ) {
1257+
return '/log-in';
1258+
}
1259+
1260+
const host = req.get( 'host' );
1261+
try {
1262+
const resolved = new URL( target, `https://${ host }` );
1263+
const destination = resolved.pathname + resolved.search + resolved.hash;
1264+
// Reject cross-origin and protocol-relative (`//host`) destinations.
1265+
if ( resolved.host === host && ! destination.startsWith( '//' ) ) {
1266+
return destination;
1267+
}
1268+
} catch {
1269+
// Fall through to the default.
1270+
}
1271+
1272+
return '/log-in';
1273+
}
1274+
12151275
export default function pages() {
12161276
const app = express();
12171277

@@ -1224,6 +1284,30 @@ export default function pages() {
12241284
app.use( setupLoggedInContext );
12251285
app.use( middlewareUnsupportedBrowser() );
12261286

1287+
// `/logout` clears browser-stored site data for the current origin via the
1288+
// `Clear-Site-Data` response header, then best-effort clears the counterpart
1289+
// origin (classic Calypso <-> Dashboard) through a hidden iframe before
1290+
// redirecting to the login page. Actual session invalidation is handled by the
1291+
// client logout flow. Registered before section routing so it responds on both
1292+
// the classic Calypso and Dashboard hostnames.
1293+
app.get( '/logout', ( req, res ) => {
1294+
res.set( 'Clear-Site-Data', '"storage", "cache", "cookies"' );
1295+
1296+
if ( req.query.embed ) {
1297+
// Loaded inside the counterpart origin's iframe: this response only needs
1298+
// to carry the header, so render an empty page and stop here.
1299+
res.send( renderJsx( 'logout', { embed: true } ) );
1300+
return;
1301+
}
1302+
1303+
res.send(
1304+
renderJsx( 'logout', {
1305+
iframeSrc: getCounterpartLogoutUrl( req ),
1306+
redirectTo: getLogoutRedirectTo( req ),
1307+
} )
1308+
);
1309+
} );
1310+
12271311
if ( ! ( isJetpackCloud() || isA8CForAgencies() || isDashboardEnv() ) ) {
12281312
wpcomPages( app );
12291313
}

0 commit comments

Comments
 (0)