Skip to content

Commit

Permalink
feat: Iterate on toolbar iframe message passing and api endpoint doma…
Browse files Browse the repository at this point in the history
…in (#81942)

Related to getsentry/sentry-toolbar#142

This moves the `request-login` and `request-logout` messages back into
the window.postMessage handler, and reserves the MessageChannel and
ports for logged-in api traffic only.
Overall the code is reorganized to better make the 3 message event
listeners clearer:
- `did-login` event listener on the window. Guarded to only accept
messages from the same domain as this page is on (aka, from sentry
itself). This is how the cookie/token gets moved around.
- `request-login` & `request-logout` event listener on the window. A
separate handler that guards for messages from the `referrerOrigin`
only. The referrer should also be the same as
window.parent.location.origin, but we can't query for that value
directly.
- `MessageChannel` ports event listener. This is only setup after the
server has validated login, and checked project permissions. The ports
are setup using `postMessage(..., referrerOrigin)` where
`referrerOrigin` was validated on the server and passed back from the
server, so we're very sure that we're setting up the MessageChannel
against the correct domain only.

These three concerns are split up, and there's some extra code to make
things more explicit. For example, we have explicit `handleXYZ()`
methods that have some copy+pasted delegation code inside. This is
intentionally separate from the `const fooMessageDispatch = {...}`
objects as these dispatchers make it easier to see which commands or
events are supported by each handler.

I've also injected `regionUrl` into the template, so the `fetch` command
doesn't rely on the SDK setting the correct sentry api domain. Instead
we can prefix api requets with the correct domain, and at the same time
insert the cookie/token value to the request. This means that
tokens/cookies will not be sent to any domain other than `regionUrl`,
which is set by the server.

[This
diagram](https://www.mermaidchart.com/play?utm_source=mermaid_js&utm_medium=banner_ad&utm_campaign=teams#pako:eNqlU7FuwjAQ_RWLJUGQQDsilamdSiWkDl1YTHxN3Sa2azswlP57z7GbhhAEFAnkJPfu3d17569BJhkMZgMDnxWIDO45zTUtV4IQRbXlGVdUWPJ8_0iocUc3wl8RDi448Y9dQCFzLup4_bQSDkArK0VVrkG7N8eezOfunBEuuI2H7rOGzBKdr2k8HRP83U7dmd7UwSZrFFqYESazqgRhU6oUCBYHXOjQFfhFbrlgcptSxh42mLDgxoIAHUclGENziMbkjQpWwMK1_FKjn3xoSK5nXVKN8RO0iZcjkKo6JVXS2JARG0st7GvhUxg3iIOl1EFIFCPIXlhSp92hFzmwRFbWE5zQukdtf6aZFLYZZq-_SLudMjapfY8apr8RR345miEl2hZHkw7eL1CtSEdphwe9X5RxFgqOSSblB4eDwsn8cGU81KQGbHwk7QxLIhS3gKgr2hFbGkR7iTQkSMv8xWhZB4WB4B0XG1rgmErLd2fbbtcJMFlSlKz5Hszm4kyvr3UbtypqkfVcFQbFgfZ9CRcrfo7mZ6lOOqJfqmFr6FMjKOwuQWUFcuLWutdhx_3_XFDHs1cIHTq-1D1oo9ptNIf7D75_AInT_g4)
might be helpful for reviewers. It shows the interactions between the
SDK, this `/iframe/` page, and the `/login-success/` page which emits
the `did-login` message. It was tough to express in that sequence
diagram the guards that are in place, i'll have to draw up another one
with that info.
What is shown are
- the 2 messages that can come in from the SDK: `request-login` and
`request-logout`
- the 1 message from the `/login-success/` page: `did-login`
- the `port-connect` system, which happens only after `state=logged-in`
- when a state change is required, we emit the `stale` event and rely on
the SDK to reload this iframe page. Internal state like that never
mutates, we take state from the server at all times.
  • Loading branch information
ryan953 authored Dec 12, 2024
1 parent 705c977 commit 3e5d70b
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 112 deletions.
249 changes: 137 additions & 112 deletions src/sentry/templates/sentry/toolbar/iframe.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{% comment %}
Template returned for requests to /iframe. The iframe serves as a proxy for Sentry API requests.
Required context variables:
- referrer: string. HTTP header from the request object.
- state: string. One of: `logged-out`, `missing-project`, `invalid-domain` or `success`.
- referrer: string. HTTP header from the request object. May have trailing `/`.
- state: string. One of: `logged-out`, `missing-project`, `invalid-domain` or `logged-in`.
- logging: any. If the value is truthy in JavaScript then debug logging will be enabled.
- organization_slug: string. The org named in the url params
- project_id_or_slug: string | int. The project named in the url params
- organizationUrl: string. Result of generate_organization_url()
- regionUrl: string. Result of generate_region_url()
{% endcomment %}
{% load sentry_helpers %}
{% load sentry_assets %}
Expand All @@ -25,151 +27,174 @@
const organizationSlug = '{{ organization_slug|escapejs }}';
const projectIdOrSlug = '{{ project_id_or_slug|escapejs }}';

const organizationUrl = '{{ organization_url|escapejs }}';
const regionUrl = '{{ region_url|escapejs }}';

// Strip the trailing `/` from the url
const referrerOrigin = new URL(referrer).origin;

function log(...args) {
if (logging) {
console.log('/toolbar/:org/:project/iframe/', ...args);
}
}

function requestAuthn(delay_ms) {
const origin = window.location.origin.endsWith('.sentry.io')
? 'https://sentry.io'
: window.location.origin;

window.open(
`${origin}/toolbar/${organizationSlug}/${projectIdOrSlug}/login-success/?delay=${delay_ms ?? '0'}`,
'sentry-toolbar-auth-popup',
'popup=true,innerWidth=800,innerHeight=550,noopener=false'
);
}

/**
* This should only be called on pageload, which is when the server has
* checked for auth, project validity, and domain config first.
*
* Also to be called when we clear auth tokens.
* This is called on pageload, and whenever login tokens are cleared.
* Pageload when the server has checked for auth, project validity, and
* domain config first, so we can trust a state that is elevated above logged-out
*/
function sendStateMessage(state) {
log('sendStateMessage(state)', { state });
window.parent.postMessage({
source: 'sentry-toolbar',
message: state
}, referrer);
function postStateMessage(state) {
log('parent.postMessage()', { state, referrerOrigin });
window.parent.postMessage({ source: 'sentry-toolbar', message: state }, referrerOrigin);
}

function listenForLoginSuccess() {
window.addEventListener('message', messageEvent => {
if (messageEvent.origin !== document.location.origin || messageEvent.data.source !== 'sentry-toolbar') {
return;
}

log('window.onMessage', messageEvent.data, messageEvent);
if (messageEvent.data.message === 'did-login') {
saveAccessToken(messageEvent.data);
window.location.reload();
}
});
function handleLoginWindowMessage(messageEvent) {
handleWindowMessage(messageEvent, document.location.origin, loginWindowMessageDispatch);
}

function getCookieValue(cookie) {
return `${cookie}; domain=${window.location.hostname}; path=/; max-age=31536000; SameSite=none; partitioned; secure`;
function handleParentWindowMessage(messageEvent) {
handleWindowMessage(messageEvent, referrerOrigin, parentWindowMessageDispatch);
}

function saveAccessToken(data) {
log('saveAccessToken', data)
if (data.cookie) {
document.cookie = getCookieValue(data.cookie);
log('Saved a cookie', document.cookie.indexOf(data.cookie) >= 0);
function handleWindowMessage(messageEvent, requiredOrigin, dispatch) {
const isValidOrigin = messageEvent.origin === requiredOrigin;
if (!isValidOrigin) {
return;
}
if (data.token) {
localStorage.setItem('accessToken', data.token);
log('Saved an accessToken to localStorage');
log('window.onMessage', messageEvent);
const { message, source } = messageEvent.data;
if (source !== 'sentry-toolbar' || !message || !(Object.hasOwn(dispatch, message))) {
return;
}
if (!data.cookie && !data.token) {
log('Unexpected: No access token found!');
}
}

function clearAuthn() {
document.cookie = getCookieValue(document.cookie.split('=').at(0) + '=');
log('Cleared the current cookie');
const accessToken = localStorage.removeItem('accessToken')
log('Removed accessToken from localStorage');

sendStateMessage('logged-out');
}

async function fetchProxy(url, init) {
// If we have an accessToken lets use it. Otherwise we presume a cookie will be set.
const accessToken = localStorage.getItem('accessToken');
const bearer = accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {};

// If either of these is invalid, or both are missing, we will
// forward the resulting 401 to the application, which will request
// tokens be destroyed and reload the iframe in an unauth state.
log('Has access info', { cookie: Boolean(document.cookie), accessToken: Boolean(accessToken) });

const initWithCreds = {
...init,
headers: { ...init.headers, ...bearer },
credentials: 'same-origin',
};
log({ initWithCreds });

const response = await fetch(url, initWithCreds);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
headers: Object.fromEntries(response.headers.entries()),
text: await response.text(),
};
dispatch[message].call(undefined, messageEvent.data);
}

function setupMessageChannel() {
function getMessagePort() {
log('setupMessageChannel()');
const { port1, port2 } = new MessageChannel();

const messageDispatch = {
'log': log,
'request-authn': requestAuthn,
'clear-authn': clearAuthn,
'fetch': fetchProxy,
};

port1.addEventListener('message', messageEvent => {
const handlePortMessage = (messageEvent) => {
log('port.onMessage', messageEvent.data);

const { $id, message } = messageEvent.data;
if (!$id) {
return; // MessageEvent is malformed, missing $id
}

if (!message.$function || !(Object.hasOwn(messageDispatch, message.$function))) {
return; // No-op without a $function to call
if (!$id || !message.$function || !(Object.hasOwn(postMessageDispatch, message.$function))) {
return;
}

Promise.resolve(messageDispatch[message.$function]
Promise.resolve(postMessageDispatch[message.$function]
.apply(undefined, message.$args || []))
.then($result => port1.postMessage({ $id, $result }))
.catch(error => port1.postMessage({ $id, $error: error }));
});
};

port1.addEventListener('message', handlePortMessage);
port1.start();

window.parent.postMessage({
source: 'sentry-toolbar',
message: 'port-connect',
}, referrer, [port2]);
return port2;
}

log('Sent', { message: 'port-connect', referrer });
function getCookieValue(cookie, domain) {
return `${cookie}; domain=${domain}; path=/; max-age=31536000; SameSite=none; partitioned; secure`;
}

log('Init', { referrer, state });
const loginWindowMessageDispatch = {
'did-login': ({ cookie, token }) => {
if (cookie) {
document.cookie = getCookieValue(cookie, window.location.hostname);
document.cookie = getCookieValue(cookie, regionUrl);
log('Saved a cookie', document.cookie.indexOf(cookie) >= 0);
}
if (token) {
localStorage.setItem('accessToken', token);
log('Saved an accessToken to localStorage');
}
if (!cookie && !token) {
log('Unexpected: No access token found!');
}

postStateMessage('stale');
},
};

const parentWindowMessageDispatch = {
'request-login': ({ delay_ms }) => {
const origin = window.location.origin.endsWith('.sentry.io')
? 'https://sentry.io'
: window.location.origin;

window.open(
`${origin}/toolbar/${organizationSlug}/${projectIdOrSlug}/login-success/?delay=${delay_ms ?? '0'}`,
'sentry-toolbar-auth-popup',
'popup=true,innerWidth=800,innerHeight=550,noopener=false'
);
log('Opened /login-success/', { delay_ms });
},

'request-logout': () => {
const cookie = document.cookie.split('=').at(0) + '=';
document.cookie = getCookieValue(cookie, window.location.hostname);
document.cookie = getCookieValue(cookie, regionUrl);
log('Cleared the current cookie');

const accessToken = localStorage.removeItem('accessToken')
log('Removed accessToken from localStorage');

postStateMessage('stale');
},
};

const postMessageDispatch = {
'log': log,

'fetch': async (path, init) => {
// If we have an accessToken lets use it. Otherwise we presume a cookie will be set.
const accessToken = localStorage.getItem('accessToken');
const bearer = accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {};

// If either of these is invalid, or both are missing, we will
// forward the resulting 401 to the application, which will request
// tokens be destroyed and reload the iframe in an unauth state.
log('Has access info', { cookie: Boolean(document.cookie), accessToken: Boolean(accessToken) });

const url = new URL('/api/0' + path, regionUrl);
const initWithCreds = {
...init,
headers: { ...init.headers, ...bearer },
credentials: 'same-origin',
};
const response = await fetch(url, initWithCreds);
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
url: response.url,
headers: Object.fromEntries(response.headers.entries()),
text: await response.text(),
};
},
};

log('Init', { referrerOrigin, state });

if (state === 'logged-out') {
const cookie = document.cookie.split('=').at(0) + '=';
document.cookie = getCookieValue(cookie, window.location.hostname);
document.cookie = getCookieValue(cookie, regionUrl);
}

setupMessageChannel();
listenForLoginSuccess();
sendStateMessage(state);
window.addEventListener('message', handleLoginWindowMessage);
window.addEventListener('message', handleParentWindowMessage);
postStateMessage(state);

if (state === 'logged-in') {
const port = getMessagePort();
window.parent.postMessage({
source: 'sentry-toolbar',
message: 'port-connect',
}, referrerOrigin, [port]);
log('parent.postMessage()', { message: 'port-connect', referrerOrigin });
}
})();
</script>
{% endscript %}
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/toolbar/views/iframe_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseBase

from sentry.api.utils import generate_region_url
from sentry.models.organization import Organization
from sentry.models.project import Project
from sentry.organizations.absolute_url import generate_organization_url
from sentry.toolbar.utils.url import is_origin_allowed
from sentry.web.frontend.base import ProjectView, region_silo_view

Expand Down Expand Up @@ -61,6 +63,8 @@ def _respond_with_state(self, state: str):
"logging": self.request.GET.get("logging", ""),
"organization_slug": self.organization_slug,
"project_id_or_slug": self.project_id_or_slug,
"organization_url": generate_organization_url(self.organization_slug),
"region_url": generate_region_url(),
},
)

Expand Down

0 comments on commit 3e5d70b

Please sign in to comment.