Skip to content

Commit 0b90acb

Browse files
committed
feat(pipeline): Detect API-driven pipelines in existing callback URL
Integration providers register callback URLs with external services (e.g. GitHub OAuth redirect). These URLs point to PipelineAdvancerView, which traditionally drives the pipeline server-side by calling pipeline.current_step() on each callback. For the new API-driven pipeline mode, we cannot change the callback URLs already registered with production integrations. Instead, this view now detects when a pipeline was initiated in API mode (api_mode flag in session state) and renders a lightweight trampoline page. The trampoline relays the callback URL query parameters (code, state, installation_id, etc.) back to the opener window via postMessage and closes itself. The frontend pipeline system then continues driving the pipeline via API endpoints. Refs VDY-36
1 parent 1e1d235 commit 0b90acb

File tree

1 file changed

+72
-2
lines changed

1 file changed

+72
-2
lines changed

src/sentry/web/frontend/pipeline_advancer.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,89 @@
1+
from urllib.parse import parse_qs
2+
13
from django.contrib import messages
2-
from django.http import HttpRequest, HttpResponseRedirect
4+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
35
from django.http.response import HttpResponseBase
46
from django.urls import reverse
57
from django.utils.translation import gettext_lazy as _
68

9+
from sentry import features
710
from sentry.identity.pipeline import IdentityPipeline
811
from sentry.integrations.pipeline import IntegrationPipeline
912
from sentry.integrations.types import IntegrationProviderSlug
1013
from sentry.organizations.absolute_url import generate_organization_url
1114
from sentry.utils.http import absolute_uri, create_redirect_url
15+
from sentry.utils.json import dumps_htmlsafe
1216
from sentry.web.frontend.base import BaseView, all_silo_view
1317

1418
# The request doesn't contain the pipeline type (pipeline information is stored
1519
# in redis keyed by the pipeline name), so we try to construct multiple pipelines
1620
# and use whichever one works.
1721
PIPELINE_CLASSES = (IntegrationPipeline, IdentityPipeline)
1822

23+
TRAMPOLINE_HTML = """\
24+
<!DOCTYPE html>
25+
<html>
26+
<head><meta charset="utf-8"></head>
27+
<body
28+
style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;
29+
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
30+
flex-direction:column;padding:2rem">
31+
<script type="module">
32+
const data = {data_json};
33+
if (window.opener) {{
34+
window.opener.postMessage(data, {origin});
35+
window.close();
36+
}} else {{
37+
document.getElementById("fallback").style.display = "flex";
38+
}}
39+
</script>
40+
<div id="fallback" style="display:none;flex-direction:column;align-items:center;gap:1.5rem;max-width:600px">
41+
<p style="font-size:1.1rem;margin:0">Unable to continue. Please restart the flow.</p>
42+
</div>
43+
</body>
44+
</html>"""
45+
46+
47+
def _render_trampoline(request: HttpRequest, pipeline: object) -> HttpResponse:
48+
"""Render a minimal page that posts callback params back to the opener."""
49+
params: dict[str, str] = {"source": "sentry-pipeline"}
50+
for key, values in parse_qs(request.META.get("QUERY_STRING", "")).items():
51+
if values:
52+
params[key] = values[0]
53+
54+
data_json = dumps_htmlsafe(params)
55+
56+
# In multi-region the opener may be on a different origin (e.g.
57+
# org-slug.sentry.io) than the trampoline (sentry.io/extensions/...),
58+
# so we need the org-specific URL. In single-region document.origin works.
59+
if features.has("system:multi-region"):
60+
org = getattr(pipeline, "organization", None)
61+
origin = dumps_htmlsafe(generate_organization_url(org.slug if org else ""))
62+
else:
63+
origin = "document.origin"
64+
65+
return HttpResponse(
66+
TRAMPOLINE_HTML.format(data_json=str(data_json), origin=str(origin)),
67+
content_type="text/html",
68+
)
69+
1970

2071
@all_silo_view
2172
class PipelineAdvancerView(BaseView):
22-
"""Gets the current pipeline from the request and executes the current step."""
73+
"""
74+
Gets the current pipeline from the request and executes the current step.
75+
76+
External services (e.g. GitHub OAuth) redirect back to this view after the
77+
user completes an action. For legacy template-driven pipelines this view
78+
processes the callback server-side via pipeline.current_step().
79+
80+
For API-driven pipelines (is_api_mode) this view does NOT process the
81+
callback. Instead it renders a lightweight trampoline page that relays the
82+
callback URL query params (code, state, installation_id, etc.) back to the
83+
opener window via postMessage and closes itself. The frontend is
84+
responsible for POSTing those params to the pipeline API endpoint to
85+
advance the pipeline.
86+
"""
2387

2488
auth_required = False
2589

@@ -50,6 +114,12 @@ def handle(self, request: HttpRequest, provider_id: str) -> HttpResponseBase:
50114
messages.add_message(request, messages.ERROR, _("Invalid request."))
51115
return self.redirect("/")
52116

117+
# If the pipeline was initiated via the API, render a trampoline page
118+
# that relays the callback params back to the opener window via
119+
# postMessage instead of processing the callback server-side.
120+
if pipeline.is_api_mode:
121+
return _render_trampoline(request, pipeline)
122+
53123
subdomain = pipeline.fetch_state("subdomain")
54124
if subdomain is not None and request.subdomain != subdomain:
55125
url_prefix = generate_organization_url(subdomain)

0 commit comments

Comments
 (0)