@specbook/ui 0.1.0 → 0.2.0
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/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/{panel-Coi9H1nv.css → panel-C-vBZrep.css} +1 -1
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/{worker-Dw3Ps3hk.js → worker-ClGb0tE1.js} +1 -1
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +5 -4
- package/src/app.tsx +7 -2
- package/src/components/EnvBadge.tsx +4 -2
- package/src/embed/panel.tsx +2 -1
- package/src/engine.ts +143 -1
- package/src/main.tsx +9 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/CodeSample.tsx +4 -3
- package/src/panel/OAuthSignIn.tsx +163 -0
- package/src/panel/Panel.tsx +8 -4
- package/src/panel/RequestPanel.tsx +2 -0
- package/src/panel/SecretsBar.tsx +35 -2
- package/src/store.ts +2 -0
- package/src/styles/panel.css +44 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/protocol.ts +3 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +11 -2
- package/src/viewer/worker.ts +12 -7
- package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
- package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
- package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
- package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
- package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
- package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
package/src/engine.ts
CHANGED
|
@@ -3,10 +3,13 @@ import {
|
|
|
3
3
|
type AuthProfile,
|
|
4
4
|
createFetchTransport,
|
|
5
5
|
createMockTransport,
|
|
6
|
+
type DiscoveredEndpoints,
|
|
6
7
|
deepMerge,
|
|
7
8
|
type Example,
|
|
8
9
|
installFaker,
|
|
10
|
+
isDiscovery,
|
|
9
11
|
type OpMeta,
|
|
12
|
+
parseDiscovery,
|
|
10
13
|
prepareFaker,
|
|
11
14
|
type RequestDef,
|
|
12
15
|
type RunEvent,
|
|
@@ -22,6 +25,17 @@ import { type ExtensionBridge, extensionTransport } from './extension'
|
|
|
22
25
|
import { absoluteBase, routingTransport, withPageSecrets } from './routing'
|
|
23
26
|
import { originOf, type SecretStore, secretStore } from './secrets'
|
|
24
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The reserved service name a sign-in's token exchange is addressed to. The dev server's proxy answers
|
|
30
|
+
* `auth:<profile>` from the profile's own `oauth.tokenUrl` (the same prefix lives in `@specbook/vite`).
|
|
31
|
+
*/
|
|
32
|
+
const AUTH_SERVICE_PREFIX = 'auth:'
|
|
33
|
+
const authService = (profile: string) => `${AUTH_SERVICE_PREFIX}${encodeURIComponent(profile)}`
|
|
34
|
+
/** Likewise for an OpenID Connect discovery document, whose host may differ from the token endpoint's. */
|
|
35
|
+
const DISCOVERY_SERVICE_PREFIX = 'discovery:'
|
|
36
|
+
const discoveryService = (profile: string) =>
|
|
37
|
+
`${DISCOVERY_SERVICE_PREFIX}${encodeURIComponent(profile)}`
|
|
38
|
+
|
|
25
39
|
export interface ExampleEntry {
|
|
26
40
|
name: string
|
|
27
41
|
source: 'generated' | 'project'
|
|
@@ -108,6 +122,17 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
108
122
|
return out
|
|
109
123
|
}
|
|
110
124
|
|
|
125
|
+
/** One line per environment that has a description, for the pickers' tooltips. */
|
|
126
|
+
async function environmentNotes(version: number): Promise<Record<string, string>> {
|
|
127
|
+
const c = await config(version)
|
|
128
|
+
const out: Record<string, string> = {}
|
|
129
|
+
for (const [name, e] of Object.entries(c.environments ?? {})) {
|
|
130
|
+
const note = (e as { description?: string } | undefined)?.description
|
|
131
|
+
if (note) out[name] = note
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
async function environments(version: number): Promise<string[]> {
|
|
112
137
|
const c = await config(version)
|
|
113
138
|
const envs = new Set<string>(['mock'])
|
|
@@ -147,12 +172,19 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
147
172
|
* One transport per run, routing each request by its service (`routeFor`). Typed values are bound to
|
|
148
173
|
* the API's origin first; placeholders left over are filled by the dev server or reported as missing.
|
|
149
174
|
*/
|
|
150
|
-
async function transportFor(
|
|
175
|
+
async function transportFor(
|
|
176
|
+
env: string,
|
|
177
|
+
proxyBase: string,
|
|
178
|
+
version: number,
|
|
179
|
+
/** extra services by name -> base URL: a sign-in's token endpoint, which is not a configured service */
|
|
180
|
+
extra: Record<string, string> = {},
|
|
181
|
+
): Promise<Transport> {
|
|
151
182
|
if (env === 'mock') return createMockTransport({ loader: mockLoader })
|
|
152
183
|
const cfg = await config(version)
|
|
153
184
|
const pageHref = globalThis.location?.href
|
|
154
185
|
const pageOrigin = globalThis.location?.origin
|
|
155
186
|
const baseOf = (service: string) => {
|
|
187
|
+
if (extra[service]) return extra[service]
|
|
156
188
|
const url = cfg.services?.[service]?.baseUrl?.[env]
|
|
157
189
|
if (!url) throw new Error(`No base URL for ${service} in the "${env}" environment`)
|
|
158
190
|
return absoluteBase(url, pageHref)
|
|
@@ -194,6 +226,14 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
194
226
|
return await inner(req, signal)
|
|
195
227
|
} catch (e) {
|
|
196
228
|
// a blocked cross-origin call surfaces as a bare TypeError("Failed to fetch")
|
|
229
|
+
if (
|
|
230
|
+
e instanceof TypeError &&
|
|
231
|
+
(req.service.startsWith(AUTH_SERVICE_PREFIX) ||
|
|
232
|
+
req.service.startsWith(DISCOVERY_SERVICE_PREFIX))
|
|
233
|
+
)
|
|
234
|
+
throw new Error(
|
|
235
|
+
`The browser could not reach the sign-in provider (${extra[req.service] ?? 'unknown'}). It probably does not allow calls from this page (CORS). Embedding the viewer on the API's origin does not help when the sign-in server is on another host; the browser extension can make the call. You can still paste an access token.`,
|
|
236
|
+
)
|
|
197
237
|
if (e instanceof TypeError)
|
|
198
238
|
throw new Error(
|
|
199
239
|
`The browser could not reach ${req.service} in "${env}". The API probably does not allow calls from this page (CORS). It works when the viewer runs on the API's own origin: embed it in the API's docs route ("Embed the viewer" in the README). Mock data keeps working.`,
|
|
@@ -219,6 +259,105 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
219
259
|
}
|
|
220
260
|
}
|
|
221
261
|
|
|
262
|
+
type SignInEndpoints = DiscoveredEndpoints & { scopes: Record<string, string> }
|
|
263
|
+
const discovered = new Map<string, Promise<DiscoveredEndpoints>>()
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Where a profile signs in: the endpoints its config writes out, or the ones its OpenID Connect
|
|
267
|
+
* discovery document names. The document is fetched the way any other call from the page is (through
|
|
268
|
+
* the dev server's proxy when there is one) and remembered for the life of the page.
|
|
269
|
+
*/
|
|
270
|
+
async function oauthEndpoints(i: {
|
|
271
|
+
env: string
|
|
272
|
+
version: number
|
|
273
|
+
profile: string
|
|
274
|
+
proxyBase?: string
|
|
275
|
+
signal?: AbortSignal
|
|
276
|
+
}): Promise<SignInEndpoints> {
|
|
277
|
+
if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
|
|
278
|
+
const oauth = ((await config(i.version)).auth?.[i.profile] as AuthProfile | undefined)?.oauth
|
|
279
|
+
if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
|
|
280
|
+
if (!isDiscovery(oauth))
|
|
281
|
+
return {
|
|
282
|
+
authorizationUrl: oauth.authorizationUrl,
|
|
283
|
+
tokenUrl: oauth.tokenUrl,
|
|
284
|
+
scopes: oauth.scopes ?? {},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const url = new URL(oauth.openIdConnectUrl)
|
|
288
|
+
let found = discovered.get(url.href)
|
|
289
|
+
if (!found) {
|
|
290
|
+
found = (async () => {
|
|
291
|
+
const service = discoveryService(i.profile)
|
|
292
|
+
const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
|
|
293
|
+
[service]: url.origin,
|
|
294
|
+
})
|
|
295
|
+
const res = await send(
|
|
296
|
+
{
|
|
297
|
+
service,
|
|
298
|
+
opId: 'oidc-discovery',
|
|
299
|
+
method: 'GET',
|
|
300
|
+
url: url.pathname,
|
|
301
|
+
query: Object.fromEntries(url.searchParams),
|
|
302
|
+
headers: { accept: 'application/json' },
|
|
303
|
+
encoding: 'none',
|
|
304
|
+
},
|
|
305
|
+
i.signal,
|
|
306
|
+
)
|
|
307
|
+
if (res.status >= 400)
|
|
308
|
+
throw new Error(`The provider's discovery document answered ${res.status}`)
|
|
309
|
+
return parseDiscovery(res.body)
|
|
310
|
+
})()
|
|
311
|
+
discovered.set(url.href, found)
|
|
312
|
+
// a failure is not remembered: the next click tries again
|
|
313
|
+
found.catch(() => discovered.delete(url.href))
|
|
314
|
+
}
|
|
315
|
+
const endpoints = await found
|
|
316
|
+
return {
|
|
317
|
+
...endpoints,
|
|
318
|
+
scopes: oauth.scopes && Object.keys(oauth.scopes).length ? oauth.scopes : endpoints.scopes,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A sign-in's code exchange, sent the way any other call from this page is: through the dev server's
|
|
324
|
+
* proxy when there is one, else direct or through the extension. The token endpoint comes from the
|
|
325
|
+
* profile in the config (or its discovery document), never from the caller, so the proxy only ever
|
|
326
|
+
* forwards to the project's own.
|
|
327
|
+
*/
|
|
328
|
+
async function oauthExchange(i: {
|
|
329
|
+
env: string
|
|
330
|
+
version: number
|
|
331
|
+
profile: string
|
|
332
|
+
fields: Record<string, string>
|
|
333
|
+
proxyBase?: string
|
|
334
|
+
signal?: AbortSignal
|
|
335
|
+
}): Promise<unknown> {
|
|
336
|
+
const { tokenUrl } = await oauthEndpoints(i)
|
|
337
|
+
const token = new URL(tokenUrl)
|
|
338
|
+
const service = authService(i.profile)
|
|
339
|
+
const send = await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version, {
|
|
340
|
+
[service]: token.origin,
|
|
341
|
+
})
|
|
342
|
+
const res = await send(
|
|
343
|
+
{
|
|
344
|
+
service,
|
|
345
|
+
opId: 'oauth-token',
|
|
346
|
+
method: 'POST',
|
|
347
|
+
url: token.pathname,
|
|
348
|
+
query: Object.fromEntries(token.searchParams),
|
|
349
|
+
headers: { accept: 'application/json' },
|
|
350
|
+
body: i.fields,
|
|
351
|
+
encoding: 'form',
|
|
352
|
+
},
|
|
353
|
+
i.signal,
|
|
354
|
+
)
|
|
355
|
+
const body = res.body as { error?: unknown } | undefined
|
|
356
|
+
if (res.status >= 400 && typeof body?.error !== 'string')
|
|
357
|
+
throw new Error(`The token endpoint answered ${res.status}`)
|
|
358
|
+
return res.body
|
|
359
|
+
}
|
|
360
|
+
|
|
222
361
|
const norm = (p: string) => p.replace(/^\.\//, '')
|
|
223
362
|
|
|
224
363
|
/** Display order: the author's examples first, generated ones last. */
|
|
@@ -400,7 +539,10 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
|
|
|
400
539
|
config,
|
|
401
540
|
environments,
|
|
402
541
|
environmentRisks,
|
|
542
|
+
environmentNotes,
|
|
403
543
|
managedSecrets,
|
|
544
|
+
oauthEndpoints,
|
|
545
|
+
oauthExchange,
|
|
404
546
|
run,
|
|
405
547
|
operationRequest,
|
|
406
548
|
operationMeta,
|
package/src/main.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render } from 'preact'
|
|
2
2
|
import { App } from './app'
|
|
3
|
+
import { handleOAuthCallback } from './oauth-callback'
|
|
3
4
|
import { applyTheme } from './theme'
|
|
4
5
|
import 'prism-code-editor/layout.css'
|
|
5
6
|
import 'prism-code-editor/autocomplete.css'
|
|
@@ -9,7 +10,14 @@ import './styles/app.css'
|
|
|
9
10
|
import './styles/panel.css'
|
|
10
11
|
|
|
11
12
|
applyTheme()
|
|
12
|
-
|
|
13
|
+
// A sign-in's return leg is handed to the tab that started it; this page then has nothing to draw. Under the
|
|
14
|
+
// viewer shell or an embed (which import this) that was settled before they got here, and running it again
|
|
15
|
+
// would strip a host page's own `?code=&state=` that the embed deliberately left alone.
|
|
16
|
+
const underShell =
|
|
17
|
+
(globalThis as { __SPECBOOK_VIEWER__?: unknown }).__SPECBOOK_VIEWER__ !== undefined
|
|
18
|
+
void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then((handled) => {
|
|
19
|
+
if (!handled) render(<App />, document.getElementById('app') as HTMLElement)
|
|
20
|
+
})
|
|
13
21
|
|
|
14
22
|
if (import.meta.hot) {
|
|
15
23
|
import.meta.hot.on('specbook:registry-changed', () => location.reload())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The return leg of a sign-in. The identity provider redirects the popup to a page with `?code=&state=`
|
|
3
|
+
* (or `?error=`). Run first on boot: when the tab that started the sign-in is waiting for this `state`
|
|
4
|
+
* it takes the result and this popup closes without drawing the app.
|
|
5
|
+
*
|
|
6
|
+
* Two ways to reach the waiting tab, because each survives what the other does not:
|
|
7
|
+
* - a same-origin `BroadcastChannel`: providers that send `Cross-Origin-Opener-Policy` sever
|
|
8
|
+
* `window.opener`, but a channel still connects the two pages;
|
|
9
|
+
* - `window.opener.postMessage`: a viewer inside an iframe on another site has partitioned storage, so its
|
|
10
|
+
* channel never hears a top-level popup, but its window is still the popup's opener.
|
|
11
|
+
*
|
|
12
|
+
* A stray visit to a URL that merely looks like a callback is told nobody is waiting and carries on as a
|
|
13
|
+
* normal page load. The hosted site's own callback page (`landing/oauth/callback/callback.js`) does the same
|
|
14
|
+
* in plain JS; keep the two in step.
|
|
15
|
+
*/
|
|
16
|
+
export const OAUTH_CHANNEL = 'specbook-oauth'
|
|
17
|
+
|
|
18
|
+
export type OAuthMessage =
|
|
19
|
+
| { type: 'callback'; state: string; code?: string; error?: string; description?: string }
|
|
20
|
+
| { type: 'ack'; state: string }
|
|
21
|
+
|
|
22
|
+
/** How long a callback page waits to be told that someone is waiting for it. */
|
|
23
|
+
const ACK_WAIT_MS = 1500
|
|
24
|
+
|
|
25
|
+
export interface CallbackOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Remove `?code=&state=` from the address even when no tab claims it (default). An embedded viewer sets
|
|
28
|
+
* this to false: the page belongs to someone else, and its query may be their own application's sign-in.
|
|
29
|
+
*/
|
|
30
|
+
stripWhenUnclaimed?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** true when this page load was a sign-in's return and has been handed over: do not boot the app. */
|
|
34
|
+
export function handleOAuthCallback(options: CallbackOptions = {}): Promise<boolean> {
|
|
35
|
+
const strip = options.stripWhenUnclaimed ?? true
|
|
36
|
+
const here = globalThis.location
|
|
37
|
+
if (!here || (typeof BroadcastChannel === 'undefined' && !globalThis.opener))
|
|
38
|
+
return Promise.resolve(false)
|
|
39
|
+
const q = new URLSearchParams(here.search)
|
|
40
|
+
const state = q.get('state')
|
|
41
|
+
if (!state || !(q.has('code') || q.has('error'))) return Promise.resolve(false)
|
|
42
|
+
|
|
43
|
+
const message: OAuthMessage = {
|
|
44
|
+
type: 'callback',
|
|
45
|
+
state,
|
|
46
|
+
...(q.get('code') ? { code: q.get('code') as string } : {}),
|
|
47
|
+
...(q.get('error') ? { error: q.get('error') as string } : {}),
|
|
48
|
+
...(q.get('error_description') ? { description: q.get('error_description') as string } : {}),
|
|
49
|
+
}
|
|
50
|
+
// the code and state are single-use and do not belong in history or a copied address
|
|
51
|
+
const clean = () => history.replaceState(null, '', here.pathname + here.hash)
|
|
52
|
+
if (strip) clean()
|
|
53
|
+
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const channel =
|
|
56
|
+
typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(OAUTH_CHANNEL)
|
|
57
|
+
let finished = false
|
|
58
|
+
const finish = (handled: boolean) => {
|
|
59
|
+
if (finished) return
|
|
60
|
+
finished = true
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
channel?.close()
|
|
63
|
+
removeEventListener('message', onMessage)
|
|
64
|
+
resolve(handled)
|
|
65
|
+
}
|
|
66
|
+
const claimed = () => {
|
|
67
|
+
if (finished) return
|
|
68
|
+
clean()
|
|
69
|
+
finish(true)
|
|
70
|
+
document.body.textContent = 'Signed in. You can close this window.'
|
|
71
|
+
window.close()
|
|
72
|
+
}
|
|
73
|
+
const isAck = (d: OAuthMessage | undefined) => d?.type === 'ack' && d.state === state
|
|
74
|
+
const onMessage = (e: MessageEvent<OAuthMessage>) => {
|
|
75
|
+
if (e.origin === here.origin && isAck(e.data)) claimed()
|
|
76
|
+
}
|
|
77
|
+
const timer = setTimeout(() => finish(false), ACK_WAIT_MS)
|
|
78
|
+
if (channel) channel.onmessage = (e: MessageEvent<OAuthMessage>) => isAck(e.data) && claimed()
|
|
79
|
+
addEventListener('message', onMessage)
|
|
80
|
+
channel?.postMessage(message)
|
|
81
|
+
try {
|
|
82
|
+
window.opener?.postMessage(message, here.origin)
|
|
83
|
+
} catch {
|
|
84
|
+
/* the opener is cross-origin or gone: the channel is the only way */
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorizeUrl,
|
|
3
|
+
createPkce,
|
|
4
|
+
newState,
|
|
5
|
+
OAuthError,
|
|
6
|
+
parseCallback,
|
|
7
|
+
parseTokenResponse,
|
|
8
|
+
tokenRequestFields,
|
|
9
|
+
} from '@specbook/core'
|
|
10
|
+
import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Page side of "Sign in": opens the provider in a popup, waits for the return leg
|
|
14
|
+
* (`oauth-callback.ts`), exchanges the code and hands back the access token. The verifier and the `state`
|
|
15
|
+
* live in this closure only: not in the URL, not in storage, not in a log.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The address a provider sends the person back to, and the one to register with it.
|
|
20
|
+
*
|
|
21
|
+
* The hosted site declares a stable, unversioned callback page (a `<meta>` its assembly adds to the viewer's
|
|
22
|
+
* `index.html`), so what people register does not change with the viewer's channel (`/v1/`, `/v2/`).
|
|
23
|
+
* Anywhere else (a self-hosted copy, an embed on a host page, the dev server) it is the page's own address.
|
|
24
|
+
*/
|
|
25
|
+
export const redirectUri = (): string => {
|
|
26
|
+
const declared = document
|
|
27
|
+
.querySelector<HTMLMetaElement>('meta[name="specbook-oauth-callback"]')
|
|
28
|
+
?.content.trim()
|
|
29
|
+
if (declared) {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(declared, location.origin).href
|
|
32
|
+
} catch {
|
|
33
|
+
/* not an address: fall back to the page's own */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return `${location.origin}${location.pathname}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SignInInput {
|
|
40
|
+
authorizationUrl: string
|
|
41
|
+
clientId: string
|
|
42
|
+
scopes: string[]
|
|
43
|
+
/** posts the form to the token endpoint and returns the parsed response body */
|
|
44
|
+
exchange(fields: Record<string, string>, signal?: AbortSignal): Promise<unknown>
|
|
45
|
+
signal?: AbortSignal
|
|
46
|
+
/** how long to wait for the person to finish (default five minutes) */
|
|
47
|
+
timeoutMs?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Call this straight from a click: the popup has to open before anything is awaited or the browser
|
|
52
|
+
* blocks it.
|
|
53
|
+
*/
|
|
54
|
+
export async function signIn(
|
|
55
|
+
i: SignInInput,
|
|
56
|
+
): Promise<{ accessToken: string; expiresIn?: number; scope?: string }> {
|
|
57
|
+
// opened blank and pointed at the provider once the challenge exists, which keeps Safari's
|
|
58
|
+
// user-activation rule happy
|
|
59
|
+
const popup = window.open('', OAUTH_CHANNEL, 'popup,width=520,height=720')
|
|
60
|
+
if (!popup)
|
|
61
|
+
throw new OAuthError(
|
|
62
|
+
'The browser blocked the sign-in window. Allow pop-ups for this page and try again.',
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const channel = new BroadcastChannel(OAUTH_CHANNEL)
|
|
66
|
+
let onWindowMessage: ((e: MessageEvent<OAuthMessage>) => void) | undefined
|
|
67
|
+
try {
|
|
68
|
+
const { verifier, challenge } = await createPkce()
|
|
69
|
+
const state = newState()
|
|
70
|
+
const redirect = redirectUri()
|
|
71
|
+
|
|
72
|
+
const returned = new Promise<OAuthMessage & { type: 'callback' }>((resolve, reject) => {
|
|
73
|
+
const timer = setTimeout(
|
|
74
|
+
() => reject(new OAuthError('Sign-in timed out. Try again.')),
|
|
75
|
+
i.timeoutMs ?? 5 * 60_000,
|
|
76
|
+
)
|
|
77
|
+
const stop = () => clearTimeout(timer)
|
|
78
|
+
i.signal?.addEventListener('abort', () => {
|
|
79
|
+
stop()
|
|
80
|
+
reject(new OAuthError('Sign-in was cancelled.'))
|
|
81
|
+
})
|
|
82
|
+
// the return arrives over the channel or from the popup as its opener; both carry the same message,
|
|
83
|
+
// and the one that arrives second is a repeat of the first
|
|
84
|
+
const take = (m: OAuthMessage | undefined, ack: (a: OAuthMessage) => void) => {
|
|
85
|
+
if (m?.type !== 'callback' || m.state !== state) return
|
|
86
|
+
stop()
|
|
87
|
+
ack({ type: 'ack', state })
|
|
88
|
+
resolve(m)
|
|
89
|
+
}
|
|
90
|
+
channel.onmessage = (e: MessageEvent<OAuthMessage>) =>
|
|
91
|
+
take(e.data, (a) => channel.postMessage(a))
|
|
92
|
+
onWindowMessage = (e: MessageEvent<OAuthMessage>) => {
|
|
93
|
+
// only this origin's own callback page: another site cannot hand us a code
|
|
94
|
+
if (e.origin !== location.origin) return
|
|
95
|
+
take(e.data, (a) => (e.source as Window | null)?.postMessage(a, location.origin))
|
|
96
|
+
}
|
|
97
|
+
addEventListener('message', onWindowMessage)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
popup.location.href = authorizeUrl({
|
|
101
|
+
authorizationUrl: i.authorizationUrl,
|
|
102
|
+
clientId: i.clientId,
|
|
103
|
+
redirectUri: redirect,
|
|
104
|
+
scopes: i.scopes,
|
|
105
|
+
state,
|
|
106
|
+
challenge,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const back = await returned
|
|
110
|
+
const { code } = parseCallback(
|
|
111
|
+
{
|
|
112
|
+
state: back.state,
|
|
113
|
+
...(back.code ? { code: back.code } : {}),
|
|
114
|
+
...(back.error ? { error: back.error } : {}),
|
|
115
|
+
...(back.description ? { error_description: back.description } : {}),
|
|
116
|
+
},
|
|
117
|
+
state,
|
|
118
|
+
)
|
|
119
|
+
const body = await i.exchange(
|
|
120
|
+
tokenRequestFields({ code, verifier, clientId: i.clientId, redirectUri: redirect }),
|
|
121
|
+
i.signal,
|
|
122
|
+
)
|
|
123
|
+
return parseTokenResponse(body)
|
|
124
|
+
} finally {
|
|
125
|
+
channel.close()
|
|
126
|
+
if (onWindowMessage) removeEventListener('message', onWindowMessage)
|
|
127
|
+
try {
|
|
128
|
+
popup.close()
|
|
129
|
+
} catch {
|
|
130
|
+
/* a severed popup cannot be closed from here; the callback page closes itself */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- small per-browser conveniences: a client id is public, and an expiry note is not a secret ----
|
|
136
|
+
|
|
137
|
+
const CLIENTS = 'specbook.oauth.clients.v1'
|
|
138
|
+
const EXPIRY = 'specbook.oauth.expiry.v1'
|
|
139
|
+
|
|
140
|
+
const readJson = (store: Storage, key: string): Record<string, any> => {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(store.getItem(key) ?? '{}')
|
|
143
|
+
} catch {
|
|
144
|
+
return {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const writeJson = (store: Storage, key: string, value: unknown) => {
|
|
148
|
+
try {
|
|
149
|
+
store.setItem(key, JSON.stringify(value))
|
|
150
|
+
} catch {
|
|
151
|
+
/* blocked or full: the page still works without it */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const recallClientId = (authorizationUrl: string): string | undefined => {
|
|
156
|
+
try {
|
|
157
|
+
return readJson(localStorage, CLIENTS)[authorizationUrl]
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
|
|
164
|
+
try {
|
|
165
|
+
writeJson(localStorage, CLIENTS, {
|
|
166
|
+
...readJson(localStorage, CLIENTS),
|
|
167
|
+
[authorizationUrl]: clientId,
|
|
168
|
+
})
|
|
169
|
+
} catch {
|
|
170
|
+
/* no storage */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
|
|
175
|
+
|
|
176
|
+
/** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
|
|
177
|
+
export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
|
|
178
|
+
try {
|
|
179
|
+
const t = readJson(sessionStorage, EXPIRY)
|
|
180
|
+
if (expiresIn === undefined) delete t[expiryKey(origin, name)]
|
|
181
|
+
else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
|
|
182
|
+
writeJson(sessionStorage, EXPIRY, t)
|
|
183
|
+
} catch {
|
|
184
|
+
/* no storage */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const expiryOf = (origin: string, name: string): number | undefined => {
|
|
189
|
+
try {
|
|
190
|
+
return readJson(sessionStorage, EXPIRY)[expiryKey(origin, name)]
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/panel/CodeSample.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import type { ComponentChildren } from 'preact'
|
|
12
12
|
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
|
13
13
|
import { engine } from '../runtime'
|
|
14
|
-
import { version } from '../store'
|
|
14
|
+
import { env, version } from '../store'
|
|
15
15
|
import { type Draft, requestParts } from './draft'
|
|
16
16
|
|
|
17
17
|
const LANG_KEY = 'specbook:code-lang'
|
|
@@ -54,14 +54,15 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
|
|
|
54
54
|
if (!live) return
|
|
55
55
|
const svc = c.services?.[service]
|
|
56
56
|
const urls = svc?.baseUrl ?? {}
|
|
57
|
-
|
|
57
|
+
// the environment being used, so a sample for the sandbox points at the sandbox
|
|
58
|
+
setBase(urls[env.value] ?? urls.live ?? Object.values(urls)[0])
|
|
58
59
|
const name = profile === 'service' ? svc?.auth : profile
|
|
59
60
|
setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
|
|
60
61
|
})
|
|
61
62
|
return () => {
|
|
62
63
|
live = false
|
|
63
64
|
}
|
|
64
|
-
}, [service, profile])
|
|
65
|
+
}, [service, profile, env.value])
|
|
65
66
|
|
|
66
67
|
const built = useMemo<{ text?: string; error?: string }>(() => {
|
|
67
68
|
try {
|