Skip to content

Commit c6c5dc8

Browse files
authored
Merge pull request #1847 from minrk/fetch-events
handle api tokens and xsrf
2 parents 430a3ea + a4e5115 commit c6c5dc8

File tree

11 files changed

+210
-48
lines changed

11 files changed

+210
-48
lines changed

binderhub/app.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -962,8 +962,7 @@ def initialize(self, *args, **kwargs):
962962
"enable_api_only_mode": self.enable_api_only_mode,
963963
}
964964
)
965-
if self.auth_enabled:
966-
self.tornado_settings["cookie_secret"] = os.urandom(32)
965+
self.tornado_settings["cookie_secret"] = os.urandom(32)
967966
if self.cors_allow_origin:
968967
self.tornado_settings.setdefault("headers", {})[
969968
"Access-Control-Allow-Origin"

binderhub/base.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,20 @@ def get_current_user(self):
137137

138138
@property
139139
def template_namespace(self):
140-
return dict(
140+
141+
ns = dict(
141142
static_url=self.static_url,
142143
banner=self.settings["banner_message"],
143-
**self.settings.get("template_variables", {}),
144+
auth_enabled=self.settings["auth_enabled"],
145+
)
146+
if self.settings["auth_enabled"]:
147+
ns["xsrf"] = self.xsrf_token.decode("ascii")
148+
ns["api_token"] = self.hub_auth.get_token(self) or ""
149+
150+
ns.update(
151+
self.settings.get("template_variables", {}),
144152
)
153+
return ns
145154

146155
def set_default_headers(self):
147156
headers = self.settings.get("headers", {})

binderhub/builder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ def _get_build_only(self):
247247

248248
return build_only
249249

250+
def redirect(self, *args, **kwargs):
251+
# disable redirect to login, which won't work for EventSource
252+
raise HTTPError(403)
253+
250254
@authenticated
251255
async def get(self, provider_prefix, _unescaped_spec):
252256
"""Get a built image for a given spec and repo provider.

binderhub/static/js/index.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* If this file gets over 200 lines of code long (not counting docs / comments), start using a framework
22
*/
33
import ClipboardJS from "clipboard";
4-
import "event-source-polyfill";
54

65
import { BinderRepository } from "@jupyterhub/binderhub-client";
76
import { updatePathText } from "./src/path";
@@ -61,12 +60,12 @@ async function build(providerSpec, log, fitAddon, path, pathType) {
6160
$(".on-build").removeClass("hidden");
6261

6362
const buildToken = $("#build-token").data("token");
63+
const apiToken = $("#api-token").data("token");
6464
const buildEndpointUrl = new URL("build", BASE_URL);
65-
const image = new BinderRepository(
66-
providerSpec,
67-
buildEndpointUrl,
65+
const image = new BinderRepository(providerSpec, buildEndpointUrl, {
66+
apiToken,
6867
buildToken,
69-
);
68+
});
7069

7170
for await (const data of image.fetch()) {
7271
// Write message to the log terminal if there is a message

binderhub/static/js/src/repo.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@ function setLabels() {
2424
*/
2525
export function updateRepoText(baseUrl) {
2626
if (Object.keys(configDict).length === 0) {
27+
const xsrf = $("#xsrf-token").data("token");
28+
const apiToken = $("#api-token").data("token");
2729
const configUrl = new URL("_config", baseUrl);
28-
fetch(configUrl).then((resp) => {
30+
const headers = {};
31+
if (apiToken && apiToken.length > 0) {
32+
headers["Authorization"] = `Bearer ${apiToken}`;
33+
} else if (xsrf && xsrf.length > 0) {
34+
headers["X-Xsrftoken"] = xsrf;
35+
}
36+
fetch(configUrl, { headers }).then((resp) => {
2937
resp.json().then((data) => {
3038
configDict = data;
3139
setLabels();

binderhub/templates/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
{% block head %}
44
<meta id="base-url" data-url="{{base_url}}">
55
<meta id="badge-base-url" data-url="{{badge_base_url}}">
6+
<meta id="api-token" data-token="{{ api_token }}">
7+
<meta id="xsrf-token" data-token="{{ xsrf }}">
68
<script src="{{static_url("dist/bundle.js")}}"></script>
79
{{ super() }}
810
{% endblock head %}

binderhub/templates/loading.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
<meta id="base-url" data-url="{{base_url}}">
1515
<meta id="badge-base-url" data-url="{{badge_base_url}}">
1616
<meta id="build-token" data-token="{{ build_token }}">
17+
<meta id="api-token" data-token="{{ api_token }}">
18+
<meta id="xsrf-token" data-token="{{ xsrf }}">
1719
{{ super() }}
1820
<script src="{{static_url("dist/bundle.js")}}"></script>
1921
<link href="{{static_url("loading.css")}}" rel="stylesheet">

js/packages/binderhub-client/lib/index.js

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,30 @@
1-
import { NativeEventSource, EventSourcePolyfill } from "event-source-polyfill";
1+
import { fetchEventSource } from "@microsoft/fetch-event-source";
22
import { EventIterator } from "event-iterator";
33

4-
// Use native browser EventSource if available, and use the polyfill if not available
5-
const EventSource = NativeEventSource || EventSourcePolyfill;
4+
function _getXSRFToken() {
5+
// from @jupyterlab/services
6+
// https://github.com/jupyterlab/jupyterlab/blob/69223102d717f3d3e9f976d32e657a4e2456e85d/packages/services/src/contents/index.ts#L1178-L1184
7+
let cookie = "";
8+
try {
9+
cookie = document.cookie;
10+
} catch (e) {
11+
// e.g. SecurityError in case of CSP Sandbox
12+
return null;
13+
}
14+
// extracts the value of the cookie named `_xsrf`
15+
// by picking up everything between `_xsrf=` and the next semicolon or end-of-line
16+
// `\b` ensures word boundaries, so it doesn't pick up `something_xsrf=`...
17+
const xsrfTokenMatch = cookie.match("\\b_xsrf=([^;]*)\\b");
18+
if (xsrfTokenMatch) {
19+
return xsrfTokenMatch[1];
20+
}
21+
return null;
22+
}
23+
24+
/* throw this to close the event stream */
25+
class EventStreamClose extends Error {}
26+
/* throw this to close the event stream */
27+
class EventStreamRetry extends Error {}
628

729
/**
830
* Build (and optionally launch) a repository by talking to a BinderHub API endpoint
@@ -12,10 +34,14 @@ export class BinderRepository {
1234
*
1335
* @param {string} providerSpec Spec of the form <provider>/<repo>/<ref> to pass to the binderhub API.
1436
* @param {URL} buildEndpointUrl API URL of the build endpoint to talk to
15-
* @param {string} [buildToken] Optional JWT based build token if this binderhub installation requires using build tokens
16-
* @param {boolean} [buildOnly] Opt out of launching built image by default by passing `build_only` param
37+
* @param {Object} [options] - optional arguments
38+
* @param {string} [options.buildToken] Optional JWT based build token if this binderhub installation requires using build tokens
39+
* @param {boolean} [options.buildOnly] Opt out of launching built image by default by passing `build_only` param
40+
* @param {string} [options.apiToken] Optional Bearer token for authenticating requests
1741
*/
18-
constructor(providerSpec, buildEndpointUrl, buildToken, buildOnly) {
42+
constructor(providerSpec, buildEndpointUrl, options) {
43+
const { apiToken, buildToken, buildOnly } = options || {};
44+
1945
this.providerSpec = providerSpec;
2046
// Make sure that buildEndpointUrl is a real URL - this ensures hostname is properly set
2147
if (!(buildEndpointUrl instanceof URL)) {
@@ -40,8 +66,10 @@ export class BinderRepository {
4066
if (buildOnly) {
4167
this.buildUrl.searchParams.append("build_only", "true");
4268
}
69+
this.apiToken = apiToken;
4370

4471
this.eventIteratorQueue = null;
72+
this.abortSignal = null;
4573
}
4674

4775
/**
@@ -67,26 +95,100 @@ export class BinderRepository {
6795
* @returns {AsyncIterable<Line>} An async iterator yielding responses from the API as they come in
6896
*/
6997
fetch() {
70-
this.eventSource = new EventSource(this.buildUrl);
98+
const headers = {};
99+
this.abortController = new AbortController();
100+
101+
if (this.apiToken && this.apiToken.length > 0) {
102+
headers["Authorization"] = `Bearer ${this.apiToken}`;
103+
} else {
104+
const xsrf = _getXSRFToken();
105+
if (xsrf) {
106+
headers["X-Xsrftoken"] = xsrf;
107+
}
108+
}
109+
// setTimeout(() => this.close(), 1000);
71110
return new EventIterator((queue) => {
72111
this.eventIteratorQueue = queue;
73-
this.eventSource.onerror = () => {
74-
queue.push({
75-
phase: "failed",
76-
message: "Failed to connect to event stream\n",
77-
});
78-
queue.stop();
79-
};
80-
81-
this.eventSource.addEventListener("message", (event) => {
82-
// console.log("message received")
83-
// console.log(event)
84-
const data = JSON.parse(event.data);
85-
// FIXME: fix case of phase/state upstream
86-
if (data.phase) {
87-
data.phase = data.phase.toLowerCase();
88-
}
89-
queue.push(data);
112+
fetchEventSource(this.buildUrl, {
113+
headers,
114+
// signal used for closing
115+
signal: this.abortController.signal,
116+
// openWhenHidden leaves connection open (matches default)
117+
// otherwise fetch-event closes connections,
118+
// which would be nice if our javascript handled restarting messages better
119+
openWhenHidden: true,
120+
onopen: (response) => {
121+
if (response.ok) {
122+
return; // everything's good
123+
} else if (
124+
response.status >= 400 &&
125+
response.status < 500 &&
126+
response.status !== 429
127+
) {
128+
queue.push({
129+
phase: "failed",
130+
message: `Failed to connect to event stream: ${response.status} - ${response.text}\n`,
131+
});
132+
throw new EventStreamClose();
133+
} else {
134+
queue.push({
135+
phase: "unknown",
136+
message: `Error connecting to event stream, retrying: ${response.status} - ${response.text}\n`,
137+
});
138+
throw new EventStreamRetry();
139+
}
140+
},
141+
142+
onclose: () => {
143+
if (!queue.isStopped) {
144+
// close called before queue finished
145+
queue.push({
146+
phase: "failed",
147+
message: `Event stream closed unexpectedly\n`,
148+
});
149+
queue.stop();
150+
// throw new EventStreamClose();
151+
}
152+
},
153+
onerror: (error) => {
154+
console.log("Event stream error", error);
155+
if (error.name === "EventStreamRetry") {
156+
// if we don't re-raise, connection will be retried;
157+
queue.push({
158+
phase: "unknown",
159+
message: `Error in event stream: ${error}\n`,
160+
});
161+
return;
162+
}
163+
if (
164+
!(error.name === "EventStreamClose" || error.name === "AbortError")
165+
) {
166+
// errors _other_ than EventStreamClose get displayed
167+
queue.push({
168+
phase: "failed",
169+
message: `Error in event stream: ${error}\n`,
170+
});
171+
}
172+
queue.stop();
173+
// need to rethrow to prevent reconnection
174+
throw error;
175+
},
176+
177+
onmessage: (event) => {
178+
if (!event.data || event.data === "") {
179+
// onmessage is called for the empty lines
180+
return;
181+
}
182+
const data = JSON.parse(event.data);
183+
// FIXME: fix case of phase/state upstream
184+
if (data.phase) {
185+
data.phase = data.phase.toLowerCase();
186+
}
187+
queue.push(data);
188+
if (data.phase === "failed") {
189+
throw new EventStreamClose();
190+
}
191+
},
90192
});
91193
});
92194
}
@@ -95,12 +197,15 @@ export class BinderRepository {
95197
* Close the EventSource connection to the BinderHub API if it is open
96198
*/
97199
close() {
98-
if (this.eventSource !== undefined) {
99-
this.eventSource.close();
100-
}
101-
if (this.eventIteratorQueue !== null) {
200+
if (this.eventIteratorQueue) {
102201
// Stop any currently running fetch() iterations
103202
this.eventIteratorQueue.stop();
203+
this.eventIteratorQueue = null;
204+
}
205+
if (this.abortController) {
206+
// close event source
207+
this.abortController.abort();
208+
this.abortController = null;
104209
}
105210
}
106211

js/packages/binderhub-client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
},
1515
"homepage": "https://github.com/jupyterhub/binderhub#readme",
1616
"dependencies": {
17-
"event-source-polyfill": "^1.0.31",
17+
"@microsoft/fetch-event-source": "^2.0.1",
1818
"event-iterator": "^2.0.0"
1919
}
2020
}

0 commit comments

Comments
 (0)