Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Iterate on toolbar iframe message passing and api endpoint domain #81942

Merged
merged 2 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = '{{ organizationUrl|escapejs }}';
const regionUrl = '{{ regionUrl|escapejs }}';
ryan953 marked this conversation as resolved.
Show resolved Hide resolved

// 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);
Comment on lines +104 to +105
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why save cookies to both of these domains?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.location.hostname is going to be used when we reload the iframe, it's like demo.sentry.io
and regionUrl is going to be used for api calls. It'll look like us.sentry.io

It's possible to make the api calls against the org-subdomain, but @silo region calls behave differently when the org is in de region :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making requests to the org subdomain will go to control and should be routed to the right region based on the org slug. However, that does require that all the APIs you're calling have org slugs in them. However, going to the region servers is better than going through the control proxy.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, so when i started calling /users/${userId}/ it required changes again to get the regionUrl.

lesson for the front-end if we ever try to get away from that window.__initialData json

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')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to work for single-tenant or self-hosted? The system.base-hostname or system.url-prefix runtime options have the bare host, and protocol + host respectively. You could use those options to make this work in other environments.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally it'll work on ST or Self-hosted.

this is here specifically because our redirect logic is messy. There's some notes here about redirects: #80003

tl/dr: In SaaS if we include the subdomain acme.sentry.io in the initial request, but the user needs to login, then we'll forget the redirect target and dump them into /issues. But if we ask for sentry.io/some-page/ then the user will end up at acme.sentry.io/some-page/ in all cases.

? '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,
"organizationUrl": generate_organization_url(self.organization_slug),
"regionUrl": generate_region_url(),
ryan953 marked this conversation as resolved.
Show resolved Hide resolved
},
)

Expand Down
Loading