@oceanum/oceanumlab 4.6.2 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +24 -2
- package/lib/DatameshWidget.js +171 -45
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +253 -0
- package/lib/auth/tokens.d.ts +95 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +17 -1
- package/lib/chatRouter.js +34 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +8 -16
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +67 -15
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -6
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +294 -59
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +393 -0
- package/src/__tests__/OceanumPanel.spec.tsx +101 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +271 -0
- package/src/__tests__/chatRouter.spec.ts +253 -17
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -0
- package/src/aiBackend.ts +150 -0
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +41 -9
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +8 -15
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +99 -33
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
2
|
+
import { Menu, Widget } from '@lumino/widgets';
|
|
3
|
+
|
|
4
|
+
import { IPendingSignIn, ServerAuth } from './serverAuth';
|
|
5
|
+
|
|
6
|
+
export const CommandIDs = {
|
|
7
|
+
signIn: 'oceanum-auth:sign-in',
|
|
8
|
+
signOut: 'oceanum-auth:sign-out',
|
|
9
|
+
account: 'oceanum-auth:account',
|
|
10
|
+
/**
|
|
11
|
+
* For other parts of oceanumlab and other extensions, such as the AI chat: resolves to
|
|
12
|
+
* the current access token, or `null` when signed out. Never rejects.
|
|
13
|
+
*/
|
|
14
|
+
accessToken: 'oceanum-auth:access-token'
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The top-bar account control: "Sign in" when signed out, the user's email with a sign-out
|
|
19
|
+
* menu when signed in.
|
|
20
|
+
*/
|
|
21
|
+
export class AccountWidget extends Widget {
|
|
22
|
+
constructor(auth: ServerAuth, commands: CommandRegistry) {
|
|
23
|
+
super({ node: document.createElement('button') });
|
|
24
|
+
this._auth = auth;
|
|
25
|
+
this._commands = commands;
|
|
26
|
+
this._menu = new Menu({ commands });
|
|
27
|
+
this._menu.addItem({ command: CommandIDs.account });
|
|
28
|
+
this._menu.addItem({ type: 'separator' });
|
|
29
|
+
this._menu.addItem({ command: CommandIDs.signOut });
|
|
30
|
+
// The application shell refuses widgets without an id.
|
|
31
|
+
this.id = 'oceanum-account';
|
|
32
|
+
this.addClass('oc-Account');
|
|
33
|
+
this.node.setAttribute('type', 'button');
|
|
34
|
+
this.node.addEventListener('click', this._onClick);
|
|
35
|
+
auth.userChanged.connect(this._render, this);
|
|
36
|
+
auth.stateChanged.connect(this._render, this);
|
|
37
|
+
void auth.ready.then(() => this._render());
|
|
38
|
+
this._render();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
dispose(): void {
|
|
42
|
+
if (this.isDisposed) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.node.removeEventListener('click', this._onClick);
|
|
46
|
+
this._menu.dispose();
|
|
47
|
+
super.dispose();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private _onClick = (): void => {
|
|
51
|
+
if (!this._auth.user) {
|
|
52
|
+
void this._commands.execute(CommandIDs.signIn);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const rect = this.node.getBoundingClientRect();
|
|
56
|
+
this._menu.open(rect.left, rect.bottom);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
private _render(): void {
|
|
60
|
+
const user = this._auth.user;
|
|
61
|
+
const waiting = !user && this._auth.pending !== null;
|
|
62
|
+
this.node.textContent = user
|
|
63
|
+
? user.email
|
|
64
|
+
: waiting
|
|
65
|
+
? 'Signing in to Oceanum.io…'
|
|
66
|
+
: 'Sign in to Oceanum.io';
|
|
67
|
+
this.node.title = user
|
|
68
|
+
? `Signed in to Oceanum.io as ${user.email}`
|
|
69
|
+
: 'Sign in to Oceanum.io';
|
|
70
|
+
this.toggleClass('oc-mod-signedIn', user !== null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private _auth: ServerAuth;
|
|
74
|
+
private _commands: CommandRegistry;
|
|
75
|
+
private _menu: Menu;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What the sign-in dialog shows: the code to confirm, and a link to the page that confirms
|
|
80
|
+
* it. A real link rather than `window.open`, which a popup blocker stops once there has
|
|
81
|
+
* been an `await` between the click and the call -- and there always has, because the code
|
|
82
|
+
* comes from the server.
|
|
83
|
+
*/
|
|
84
|
+
export class SignInBody extends Widget {
|
|
85
|
+
constructor(pending: IPendingSignIn) {
|
|
86
|
+
super();
|
|
87
|
+
this.addClass('oc-SignIn');
|
|
88
|
+
|
|
89
|
+
const intro = document.createElement('p');
|
|
90
|
+
intro.textContent =
|
|
91
|
+
'Open the Oceanum.io sign-in page, and check that it shows this code:';
|
|
92
|
+
|
|
93
|
+
const code = document.createElement('div');
|
|
94
|
+
code.className = 'oc-SignIn-code';
|
|
95
|
+
code.textContent = pending.userCode;
|
|
96
|
+
|
|
97
|
+
const link = document.createElement('a');
|
|
98
|
+
link.className = 'oc-SignIn-link jp-mod-styled jp-mod-accept';
|
|
99
|
+
link.href = pending.verificationUriComplete;
|
|
100
|
+
link.target = '_blank';
|
|
101
|
+
link.rel = 'noopener noreferrer';
|
|
102
|
+
link.textContent = 'Open the sign-in page';
|
|
103
|
+
|
|
104
|
+
const note = document.createElement('p');
|
|
105
|
+
note.className = 'oc-SignIn-note';
|
|
106
|
+
const minutes = Math.max(1, Math.round(pending.expiresIn / 60));
|
|
107
|
+
note.textContent =
|
|
108
|
+
`This closes by itself once you have signed in. The code lasts ${minutes} ` +
|
|
109
|
+
`minute${minutes === 1 ? '' : 's'}. Only confirm a code you asked for yourself.`;
|
|
110
|
+
|
|
111
|
+
this.node.append(intro, code, link, note);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ColorScheme, IOceanumUser } from './tokens';
|
|
2
|
+
|
|
3
|
+
const CLAIM = 'https://oceanum.io/';
|
|
4
|
+
const COLOR_SCHEMES: readonly ColorScheme[] = ['light', 'dark', 'auto'];
|
|
5
|
+
|
|
6
|
+
/** Build the user from Auth0 ID token claims; `null` if the claims lack an identity. */
|
|
7
|
+
export function userFromClaims(
|
|
8
|
+
claims: Record<string, unknown> | null | undefined
|
|
9
|
+
): IOceanumUser | null {
|
|
10
|
+
if (!claims) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const str = (value: unknown): string | null =>
|
|
14
|
+
typeof value === 'string' && value.length > 0 ? value : null;
|
|
15
|
+
const sub = str(claims.sub);
|
|
16
|
+
const email = str(claims.email) ?? str(claims[`${CLAIM}email`]);
|
|
17
|
+
if (!sub || !email) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const scheme = claims[`${CLAIM}color_scheme`];
|
|
21
|
+
return {
|
|
22
|
+
sub,
|
|
23
|
+
email,
|
|
24
|
+
name: str(claims.name),
|
|
25
|
+
activeOrg: str(claims[`${CLAIM}active_org`]),
|
|
26
|
+
colorScheme: COLOR_SCHEMES.includes(scheme as ColorScheme)
|
|
27
|
+
? (scheme as ColorScheme)
|
|
28
|
+
: null
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JupyterFrontEnd,
|
|
3
|
+
JupyterFrontEndPlugin
|
|
4
|
+
} from '@jupyterlab/application';
|
|
5
|
+
import { Dialog, ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
6
|
+
import { PageConfig, URLExt } from '@jupyterlab/coreutils';
|
|
7
|
+
import { ServerConnection } from '@jupyterlab/services';
|
|
8
|
+
|
|
9
|
+
import { AccountWidget, CommandIDs, SignInBody } from './accountWidget';
|
|
10
|
+
import {
|
|
11
|
+
AuthRequest,
|
|
12
|
+
readServerAuthOption,
|
|
13
|
+
SERVER_AUTH_OPTION,
|
|
14
|
+
ServerAuth
|
|
15
|
+
} from './serverAuth';
|
|
16
|
+
import { IOceanumAuth } from './tokens';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The id other deployments disable. notebook.oceanum.io provides IOceanumAuth from its own
|
|
20
|
+
* Oceanum widget extension, and Lumino lets a second provider silently replace the first
|
|
21
|
+
* rather than refusing it, so that site lists this id under `disabledExtensions`.
|
|
22
|
+
*/
|
|
23
|
+
export const DEVICE_AUTH_PLUGIN_ID = '@oceanum/oceanumlab:device-auth';
|
|
24
|
+
|
|
25
|
+
function serverRequest(settings: ServerConnection.ISettings): AuthRequest {
|
|
26
|
+
return async (action, method) => {
|
|
27
|
+
const url = URLExt.join(settings.baseUrl, 'oceanum', 'auth', action);
|
|
28
|
+
const response = await ServerConnection.makeRequest(
|
|
29
|
+
url,
|
|
30
|
+
{ method },
|
|
31
|
+
settings
|
|
32
|
+
);
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new ServerConnection.ResponseError(response);
|
|
35
|
+
}
|
|
36
|
+
return response.json();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Show the code and the link until the sign-in completes, fails, or is cancelled. */
|
|
41
|
+
async function showSignIn(auth: ServerAuth): Promise<void> {
|
|
42
|
+
const pending = auth.pending;
|
|
43
|
+
if (!pending) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const dialog = new Dialog({
|
|
47
|
+
title: 'Sign in to Oceanum.io',
|
|
48
|
+
body: new SignInBody(pending),
|
|
49
|
+
buttons: [Dialog.cancelButton()]
|
|
50
|
+
});
|
|
51
|
+
const closeWhenSettled = (): void => {
|
|
52
|
+
if (auth.pending === null) {
|
|
53
|
+
dialog.reject();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
auth.stateChanged.connect(closeWhenSettled);
|
|
57
|
+
auth.userChanged.connect(closeWhenSettled);
|
|
58
|
+
try {
|
|
59
|
+
await dialog.launch();
|
|
60
|
+
} finally {
|
|
61
|
+
auth.stateChanged.disconnect(closeWhenSettled);
|
|
62
|
+
auth.userChanged.disconnect(closeWhenSettled);
|
|
63
|
+
}
|
|
64
|
+
if (auth.pending) {
|
|
65
|
+
// Still waiting, so it was the user who closed it.
|
|
66
|
+
await auth.cancelSignIn();
|
|
67
|
+
} else if (!auth.user && auth.error) {
|
|
68
|
+
Notification.warning(`Oceanum.io sign-in did not complete: ${auth.error}`, {
|
|
69
|
+
autoClose: false
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const deviceAuthPlugin: JupyterFrontEndPlugin<IOceanumAuth> = {
|
|
75
|
+
id: DEVICE_AUTH_PLUGIN_ID,
|
|
76
|
+
description:
|
|
77
|
+
'Oceanum.io sign-in by device code, run by the Jupyter server extension.',
|
|
78
|
+
autoStart: true,
|
|
79
|
+
provides: IOceanumAuth,
|
|
80
|
+
optional: [ICommandPalette],
|
|
81
|
+
activate: (
|
|
82
|
+
app: JupyterFrontEnd,
|
|
83
|
+
palette: ICommandPalette | null
|
|
84
|
+
): IOceanumAuth => {
|
|
85
|
+
const environment = readServerAuthOption(
|
|
86
|
+
PageConfig.getOption(SERVER_AUTH_OPTION),
|
|
87
|
+
window.location.hostname
|
|
88
|
+
);
|
|
89
|
+
const auth = new ServerAuth({
|
|
90
|
+
environment,
|
|
91
|
+
request: serverRequest(app.serviceManager.serverSettings),
|
|
92
|
+
onSessionEnded: message =>
|
|
93
|
+
Notification.warning(message, { autoClose: false })
|
|
94
|
+
});
|
|
95
|
+
if (!environment) {
|
|
96
|
+
// The server offers no sign-in (`c.OceanumLab.sign_in = "off"`), or there is no
|
|
97
|
+
// oceanumlab server extension at all. Consumers see an auth with no environment and
|
|
98
|
+
// say sign-in is not configured; nothing is added to the top bar.
|
|
99
|
+
return auth;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
document.addEventListener('visibilitychange', () => {
|
|
103
|
+
if (document.visibilityState === 'visible') {
|
|
104
|
+
void auth.refresh();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { commands } = app;
|
|
109
|
+
let dialogOpen = false;
|
|
110
|
+
commands.addCommand(CommandIDs.signIn, {
|
|
111
|
+
label: 'Sign in to Oceanum.io',
|
|
112
|
+
isVisible: () => !auth.user,
|
|
113
|
+
execute: async () => {
|
|
114
|
+
await auth.signIn();
|
|
115
|
+
if (auth.pending && !dialogOpen) {
|
|
116
|
+
dialogOpen = true;
|
|
117
|
+
try {
|
|
118
|
+
await showSignIn(auth);
|
|
119
|
+
} finally {
|
|
120
|
+
dialogOpen = false;
|
|
121
|
+
}
|
|
122
|
+
} else if (!auth.user && !auth.pending && auth.error) {
|
|
123
|
+
Notification.warning(
|
|
124
|
+
`Oceanum.io sign-in could not start: ${auth.error}`,
|
|
125
|
+
{ autoClose: false }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
commands.addCommand(CommandIDs.signOut, {
|
|
131
|
+
label: 'Sign out of Oceanum.io',
|
|
132
|
+
isVisible: () => auth.user !== null,
|
|
133
|
+
execute: () => auth.signOut()
|
|
134
|
+
});
|
|
135
|
+
commands.addCommand(CommandIDs.account, {
|
|
136
|
+
label: () =>
|
|
137
|
+
auth.user ? `Signed in as ${auth.user.email}` : 'Not signed in',
|
|
138
|
+
isEnabled: () => false,
|
|
139
|
+
execute: () => undefined
|
|
140
|
+
});
|
|
141
|
+
commands.addCommand(CommandIDs.accessToken, {
|
|
142
|
+
label: 'Oceanum.io access token',
|
|
143
|
+
isVisible: () => false,
|
|
144
|
+
execute: () => auth.getAccessToken().catch((): null => null)
|
|
145
|
+
});
|
|
146
|
+
auth.userChanged.connect(() => commands.notifyCommandChanged());
|
|
147
|
+
|
|
148
|
+
if (palette) {
|
|
149
|
+
palette.addItem({ command: CommandIDs.signIn, category: 'Oceanum' });
|
|
150
|
+
palette.addItem({ command: CommandIDs.signOut, category: 'Oceanum' });
|
|
151
|
+
}
|
|
152
|
+
app.shell.add(new AccountWidget(auth, commands), 'top', { rank: 1000 });
|
|
153
|
+
return auth;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oceanum.io sign-in on an ordinary JupyterLab, where the Jupyter server runs the OAuth
|
|
3
|
+
* device grant (oceanumlab/auth.py) and this only reflects its state.
|
|
4
|
+
*
|
|
5
|
+
* A browser sign-in cannot work here. Popup, redirect and the silent iframe check all send
|
|
6
|
+
* the page's own origin to Auth0 as the callback, Auth0 only accepts addresses registered in
|
|
7
|
+
* advance, and it cannot wildcard a port or an arbitrary host -- so everywhere but Oceanum's
|
|
8
|
+
* own sites the answer is "Callback URL mismatch". The device grant has no callback: the
|
|
9
|
+
* user opens a link and confirms a code, and the server collects the tokens.
|
|
10
|
+
*
|
|
11
|
+
* The refresh token and the device code never reach this page. It is handed short-lived
|
|
12
|
+
* access tokens only.
|
|
13
|
+
*/
|
|
14
|
+
import { PromiseDelegate } from '@lumino/coreutils';
|
|
15
|
+
import { IDisposable } from '@lumino/disposable';
|
|
16
|
+
import { ISignal, Signal } from '@lumino/signaling';
|
|
17
|
+
|
|
18
|
+
import { userFromClaims } from './claims';
|
|
19
|
+
import {
|
|
20
|
+
IOceanumAuth,
|
|
21
|
+
IOceanumEnvironment,
|
|
22
|
+
IOceanumServiceUrls,
|
|
23
|
+
IOceanumUser
|
|
24
|
+
} from './tokens';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The page option the server extension publishes when it runs the device sign-in. Must
|
|
28
|
+
* match SERVER_AUTH_OPTION in oceanumlab/__init__.py.
|
|
29
|
+
*/
|
|
30
|
+
export const SERVER_AUTH_OPTION = 'oceanumServerAuth';
|
|
31
|
+
|
|
32
|
+
/** A sign-in waiting on the user: the code to confirm, and where to confirm it. */
|
|
33
|
+
export interface IPendingSignIn {
|
|
34
|
+
readonly userCode: string;
|
|
35
|
+
readonly verificationUri: string;
|
|
36
|
+
/** The verification page with the code already filled in. */
|
|
37
|
+
readonly verificationUriComplete: string;
|
|
38
|
+
/** Seconds until the code lapses. */
|
|
39
|
+
readonly expiresIn: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface IServerStatus {
|
|
43
|
+
state: 'signedOut' | 'pending' | 'signedIn';
|
|
44
|
+
claims: Record<string, unknown> | null;
|
|
45
|
+
pending: IPendingSignIn | null;
|
|
46
|
+
error: string | null;
|
|
47
|
+
accessToken?: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One exchange with the server extension's `/oceanum/auth/<action>` handler. */
|
|
51
|
+
export type AuthRequest = (
|
|
52
|
+
action: 'status' | 'token' | 'start' | 'cancel' | 'signout',
|
|
53
|
+
method: 'GET' | 'POST'
|
|
54
|
+
) => Promise<unknown>;
|
|
55
|
+
|
|
56
|
+
function httpUrl(value: unknown): string | null {
|
|
57
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(value);
|
|
62
|
+
return url.protocol === 'https:' || url.protocol === 'http:'
|
|
63
|
+
? url.href.replace(/\/+$/, '')
|
|
64
|
+
: null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Oceanum deployment the server signs in to, from the page option, or `null` when the
|
|
72
|
+
* server offers no device sign-in (or the option is malformed, which is treated the same).
|
|
73
|
+
*/
|
|
74
|
+
export function readServerAuthOption(
|
|
75
|
+
option: string | undefined,
|
|
76
|
+
hostname: string
|
|
77
|
+
): IOceanumEnvironment | null {
|
|
78
|
+
if (!option) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
let raw: unknown;
|
|
82
|
+
try {
|
|
83
|
+
raw = JSON.parse(option);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const config = raw as Record<string, unknown>;
|
|
91
|
+
const urlsIn = (config.urls ?? {}) as Record<string, unknown>;
|
|
92
|
+
const datamesh = httpUrl(urlsIn.datamesh);
|
|
93
|
+
const specs = httpUrl(urlsIn.specs);
|
|
94
|
+
const manage = httpUrl(urlsIn.manage);
|
|
95
|
+
if (
|
|
96
|
+
typeof config.auth0Domain !== 'string' ||
|
|
97
|
+
typeof config.clientId !== 'string' ||
|
|
98
|
+
!datamesh ||
|
|
99
|
+
!specs ||
|
|
100
|
+
!manage
|
|
101
|
+
) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const urls: IOceanumServiceUrls = {
|
|
105
|
+
datamesh,
|
|
106
|
+
specs,
|
|
107
|
+
manage,
|
|
108
|
+
...(httpUrl(urlsIn.datameshUi)
|
|
109
|
+
? { datameshUi: httpUrl(urlsIn.datameshUi) as string }
|
|
110
|
+
: {}),
|
|
111
|
+
...(httpUrl(urlsIn.ai) ? { ai: httpUrl(urlsIn.ai) as string } : {})
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
// The device grant is indifferent to where the page is served from, so this is simply
|
|
115
|
+
// wherever that is, rather than a list to match against.
|
|
116
|
+
hosts: [hostname.toLowerCase()],
|
|
117
|
+
auth0Domain: config.auth0Domain,
|
|
118
|
+
clientId: config.clientId,
|
|
119
|
+
oceanumDomain:
|
|
120
|
+
typeof config.oceanumDomain === 'string' ? config.oceanumDomain : '',
|
|
121
|
+
signInRedirect: false,
|
|
122
|
+
urls
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface IServerAuthOptions {
|
|
127
|
+
/** The deployment the server signs in to, or `null` when it offers no sign-in. */
|
|
128
|
+
environment: IOceanumEnvironment | null;
|
|
129
|
+
request: AuthRequest;
|
|
130
|
+
/** Called when a session ends other than by `signOut()`. */
|
|
131
|
+
onSessionEnded?: (message: string) => void;
|
|
132
|
+
/** How often to ask the server whether a pending sign-in has completed. */
|
|
133
|
+
pendingPollMs?: number;
|
|
134
|
+
/** How often to pick up a refreshed access token while signed in. */
|
|
135
|
+
tokenPollMs?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class ServerAuth implements IOceanumAuth, IDisposable {
|
|
139
|
+
constructor(options: IServerAuthOptions) {
|
|
140
|
+
this.environment = options.environment;
|
|
141
|
+
this._request = options.request;
|
|
142
|
+
this._onSessionEnded = options.onSessionEnded;
|
|
143
|
+
this._pendingPollMs = options.pendingPollMs ?? 2000;
|
|
144
|
+
this._tokenPollMs = options.tokenPollMs ?? 60000;
|
|
145
|
+
if (!this.environment) {
|
|
146
|
+
this._ready.resolve();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// A server that signed in before this page loaded (a reload, a second tab) is already
|
|
150
|
+
// signed in; `ready` waits for that answer so nothing downstream sees a false sign-out.
|
|
151
|
+
void this._sync('token').finally(() => this._ready.resolve());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
readonly environment: IOceanumEnvironment | null;
|
|
155
|
+
|
|
156
|
+
get urls(): IOceanumServiceUrls | null {
|
|
157
|
+
return this.environment?.urls ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
get ready(): Promise<void> {
|
|
161
|
+
return this._ready.promise;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get user(): IOceanumUser | null {
|
|
165
|
+
return this._user;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get userChanged(): ISignal<IOceanumAuth, IOceanumUser | null> {
|
|
169
|
+
return this._userChanged;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get tokenChanged(): ISignal<IOceanumAuth, string | null> {
|
|
173
|
+
return this._tokenChanged;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The sign-in waiting on the user, if any. */
|
|
177
|
+
get pending(): IPendingSignIn | null {
|
|
178
|
+
return this._pending;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Why the last sign-in attempt ended, if it failed. */
|
|
182
|
+
get error(): string | null {
|
|
183
|
+
return this._error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Emits when `pending` or `error` changes. */
|
|
187
|
+
get stateChanged(): ISignal<ServerAuth, void> {
|
|
188
|
+
return this._stateChanged;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async signIn(): Promise<void> {
|
|
192
|
+
if (!this.environment || this._user) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
await this._sync('start', 'POST');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Abandon a sign-in that is waiting on the user. */
|
|
199
|
+
async cancelSignIn(): Promise<void> {
|
|
200
|
+
if (this._pending) {
|
|
201
|
+
await this._sync('cancel', 'POST');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async signOut(): Promise<void> {
|
|
206
|
+
this._signingOut = true;
|
|
207
|
+
try {
|
|
208
|
+
await this._sync('signout', 'POST');
|
|
209
|
+
} finally {
|
|
210
|
+
this._signingOut = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async getAccessToken(): Promise<string | null> {
|
|
215
|
+
if (!this.environment || !this._user) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
await this._sync('token');
|
|
219
|
+
return this._token;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Re-read the server's state, e.g. when the tab becomes visible again. */
|
|
223
|
+
async refresh(): Promise<void> {
|
|
224
|
+
if (this.environment) {
|
|
225
|
+
await this._sync(this._user ? 'token' : 'status');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
get isDisposed(): boolean {
|
|
230
|
+
return this._disposed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
dispose(): void {
|
|
234
|
+
if (this._disposed) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this._disposed = true;
|
|
238
|
+
this._clearTimer();
|
|
239
|
+
Signal.clearData(this);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async _sync(
|
|
243
|
+
action: 'status' | 'token' | 'start' | 'cancel' | 'signout',
|
|
244
|
+
method: 'GET' | 'POST' = 'GET'
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
let status: IServerStatus;
|
|
247
|
+
try {
|
|
248
|
+
status = (await this._request(action, method)) as IServerStatus;
|
|
249
|
+
} catch (reason) {
|
|
250
|
+
// The Jupyter server being briefly unreachable is not a sign-out: keep what we have
|
|
251
|
+
// and let the next poll find out.
|
|
252
|
+
console.warn(`Oceanum.io sign-in: ${action} failed`, reason);
|
|
253
|
+
this._schedule();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (this._disposed || !status || typeof status.state !== 'string') {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this._apply(status);
|
|
260
|
+
if (status.state === 'signedIn' && action !== 'token') {
|
|
261
|
+
// A sign-in that has just completed: fetch the token it produced.
|
|
262
|
+
await this._sync('token');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private _apply(status: IServerStatus): void {
|
|
267
|
+
const user =
|
|
268
|
+
status.state === 'signedIn' ? userFromClaims(status.claims) : null;
|
|
269
|
+
const pending = status.state === 'pending' ? status.pending : null;
|
|
270
|
+
const error = status.error ?? null;
|
|
271
|
+
|
|
272
|
+
if (
|
|
273
|
+
pending?.userCode !== this._pending?.userCode ||
|
|
274
|
+
error !== this._error
|
|
275
|
+
) {
|
|
276
|
+
this._pending = pending;
|
|
277
|
+
this._error = error;
|
|
278
|
+
this._stateChanged.emit();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const wasSignedIn = this._user !== null;
|
|
282
|
+
if ((user?.sub ?? null) !== (this._user?.sub ?? null)) {
|
|
283
|
+
this._user = user;
|
|
284
|
+
this._userChanged.emit(user);
|
|
285
|
+
if (wasSignedIn && !user && !this._signingOut) {
|
|
286
|
+
this._onSessionEnded?.(
|
|
287
|
+
error ?? 'Your Oceanum.io session has ended. Sign in again.'
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const token = user ? (status.accessToken ?? this._token) : null;
|
|
293
|
+
if (token !== this._token) {
|
|
294
|
+
this._token = token;
|
|
295
|
+
this._tokenChanged.emit(token);
|
|
296
|
+
}
|
|
297
|
+
this._schedule();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Poll quickly while a sign-in is pending, slowly while signed in, not at all otherwise. */
|
|
301
|
+
private _schedule(): void {
|
|
302
|
+
this._clearTimer();
|
|
303
|
+
if (this._disposed) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (this._pending) {
|
|
307
|
+
this._timer = setTimeout(
|
|
308
|
+
() => void this._sync('status'),
|
|
309
|
+
this._pendingPollMs
|
|
310
|
+
);
|
|
311
|
+
} else if (this._user) {
|
|
312
|
+
this._timer = setTimeout(
|
|
313
|
+
() => void this._sync('token'),
|
|
314
|
+
this._tokenPollMs
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private _clearTimer(): void {
|
|
320
|
+
if (this._timer !== null) {
|
|
321
|
+
clearTimeout(this._timer);
|
|
322
|
+
this._timer = null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private _request: AuthRequest;
|
|
327
|
+
private _onSessionEnded: ((message: string) => void) | undefined;
|
|
328
|
+
private _pendingPollMs: number;
|
|
329
|
+
private _tokenPollMs: number;
|
|
330
|
+
private _ready = new PromiseDelegate<void>();
|
|
331
|
+
private _user: IOceanumUser | null = null;
|
|
332
|
+
private _token: string | null = null;
|
|
333
|
+
private _pending: IPendingSignIn | null = null;
|
|
334
|
+
private _error: string | null = null;
|
|
335
|
+
private _signingOut = false;
|
|
336
|
+
private _disposed = false;
|
|
337
|
+
private _timer: ReturnType<typeof setTimeout> | null = null;
|
|
338
|
+
private _userChanged = new Signal<IOceanumAuth, IOceanumUser | null>(this);
|
|
339
|
+
private _tokenChanged = new Signal<IOceanumAuth, string | null>(this);
|
|
340
|
+
private _stateChanged = new Signal<ServerAuth, void>(this);
|
|
341
|
+
}
|