-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 %} | ||
|
@@ -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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to work for single-tenant or self-hosted? The There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
? '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 %} | ||
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 likedemo.sentry.io
and
regionUrl
is going to be used for api calls. It'll look likeus.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 :(
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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