@specbook/core 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The runner, mock engine, template engine and shared proxy behind Specbook: Storybook for APIs.",
5
5
  "keywords": [
6
6
  "specbook",
package/src/config.ts CHANGED
@@ -11,6 +11,8 @@ export interface EnvironmentConfig {
11
11
  * "prod", amber otherwise (`mock` is always neutral).
12
12
  */
13
13
  risk?: 'production' | 'sandbox'
14
+ /** One line on what this environment is; shown as the tooltip of its entry in the pickers. */
15
+ description?: string
14
16
  }
15
17
 
16
18
  export interface ServiceConfig<E extends string = string> {
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export { fromFormRows, fromRows, toFormRows, toRows } from './example'
6
6
  export type { FakerConfig } from './faker'
7
7
  export { faker, fakerLocaleNames, installFaker, prepareFaker, setFaker } from './faker'
8
8
  export * from './mock'
9
+ export * from './oauth'
9
10
  export * from './proxy/index'
10
11
  export * from './reporters/index'
11
12
  export { defineOperation, request } from './request'
@@ -15,7 +16,15 @@ export type { Sequence, SequenceMeta, StepCtx, StepNode } from './sequence'
15
16
  export { sequence } from './sequence'
16
17
  export type { SnippetInput, SnippetLang } from './snippets'
17
18
  export { snippetLangs, toSnippet } from './snippets'
18
- export { authFromSpec, type JsonAuthProfile, liveBaseUrl, type SpecAuth } from './spec-config'
19
+ export {
20
+ authFromSpec,
21
+ type JsonAuthProfile,
22
+ liveBaseUrl,
23
+ MAX_SERVERS,
24
+ openApiServers,
25
+ type ServerEntry,
26
+ type SpecAuth,
27
+ } from './spec-config'
19
28
  export {
20
29
  collectPlaceholders,
21
30
  deepMerge,
package/src/oauth.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * OAuth 2.0 authorization code with PKCE (RFC 7636), as pure helpers over Web Crypto so the browser and
3
+ * Node run the same code. Nothing here stores anything: a verifier is held by the caller, in memory.
4
+ */
5
+
6
+ export class OAuthError extends Error {
7
+ constructor(message: string) {
8
+ super(message)
9
+ this.name = 'OAuthError'
10
+ }
11
+ }
12
+
13
+ /** The endpoints are written out: an OAuth 2.0 authorization code flow. */
14
+ export interface OAuthEndpoints {
15
+ authorizationUrl: string
16
+ tokenUrl: string
17
+ /** scope name -> description */
18
+ scopes?: Record<string, string>
19
+ /** a public identifier; pre-fills the sign-in prompt */
20
+ clientId?: string
21
+ }
22
+
23
+ /** The endpoints are found in an OpenID Connect discovery document. */
24
+ export interface OAuthDiscovery {
25
+ openIdConnectUrl: string
26
+ scopes?: Record<string, string>
27
+ clientId?: string
28
+ }
29
+
30
+ /** What a spec or a config profile declares for an interactive sign-in. */
31
+ export type OAuthSettings = OAuthEndpoints | OAuthDiscovery
32
+
33
+ export const isDiscovery = (o: OAuthSettings): o is OAuthDiscovery => 'openIdConnectUrl' in o
34
+
35
+ /** Endpoints read from a discovery document. */
36
+ export interface DiscoveredEndpoints {
37
+ authorizationUrl: string
38
+ tokenUrl: string
39
+ /** scope name -> '' (a discovery document lists names only) */
40
+ scopes: Record<string, string>
41
+ }
42
+
43
+ /**
44
+ * The two endpoints a sign-in needs, from `/.well-known/openid-configuration`. Only the access token is
45
+ * used, so nothing else in the document (ID-token signing keys, `nonce` support) matters here.
46
+ */
47
+ export function parseDiscovery(json: unknown): DiscoveredEndpoints {
48
+ const d = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
49
+ const url = (v: unknown) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : undefined)
50
+ const authorizationUrl = url(d.authorization_endpoint)
51
+ const tokenUrl = url(d.token_endpoint)
52
+ if (!authorizationUrl || !tokenUrl)
53
+ throw new OAuthError('The provider’s discovery document has no authorization or token endpoint')
54
+ const supported = Array.isArray(d.scopes_supported)
55
+ ? d.scopes_supported.filter((x): x is string => typeof x === 'string')
56
+ : []
57
+ // `openid` is what makes it an OpenID Connect request; list it first even when the document omits it
58
+ const names = ['openid', ...supported.filter((x) => x !== 'openid')]
59
+ return { authorizationUrl, tokenUrl, scopes: Object.fromEntries(names.map((n) => [n, ''])) }
60
+ }
61
+
62
+ const base64url = (bytes: Uint8Array): string => {
63
+ let bin = ''
64
+ for (const b of bytes) bin += String.fromCharCode(b)
65
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
66
+ }
67
+
68
+ const random = (bytes: number) => base64url(crypto.getRandomValues(new Uint8Array(bytes)))
69
+
70
+ /** `code_challenge` for a verifier: BASE64URL(SHA-256(verifier)). */
71
+ export async function challengeFor(verifier: string): Promise<string> {
72
+ // WebCrypto's `subtle` exists only on a secure page (https, or localhost); a viewer served over plain
73
+ // http, or framed inside such a page, has none. Say so, rather than fail on `undefined.digest`.
74
+ if (!globalThis.crypto?.subtle)
75
+ throw new OAuthError(
76
+ 'Sign-in needs a secure page (https, or localhost). Open the viewer over https, or paste an access token.',
77
+ )
78
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
79
+ return base64url(new Uint8Array(digest))
80
+ }
81
+
82
+ /** A fresh verifier (64 characters from the unreserved set) and its S256 challenge. */
83
+ export async function createPkce(): Promise<{ verifier: string; challenge: string }> {
84
+ const verifier = random(48)
85
+ return { verifier, challenge: await challengeFor(verifier) }
86
+ }
87
+
88
+ export const newState = (): string => random(24)
89
+
90
+ export function authorizeUrl(o: {
91
+ authorizationUrl: string
92
+ clientId: string
93
+ redirectUri: string
94
+ scopes?: string[]
95
+ state: string
96
+ challenge: string
97
+ }): string {
98
+ const url = new URL(o.authorizationUrl)
99
+ url.searchParams.set('response_type', 'code')
100
+ url.searchParams.set('client_id', o.clientId)
101
+ url.searchParams.set('redirect_uri', o.redirectUri)
102
+ if (o.scopes?.length) url.searchParams.set('scope', o.scopes.join(' '))
103
+ url.searchParams.set('state', o.state)
104
+ url.searchParams.set('code_challenge', o.challenge)
105
+ url.searchParams.set('code_challenge_method', 'S256')
106
+ return url.toString()
107
+ }
108
+
109
+ /** The code from the redirect, once `state` has been checked; throws what the server or the check said. */
110
+ export function parseCallback(
111
+ params: URLSearchParams | Record<string, string | null | undefined>,
112
+ expectedState: string,
113
+ ): { code: string } {
114
+ const get = (k: string) =>
115
+ (params instanceof URLSearchParams ? params.get(k) : params[k]) ?? undefined
116
+ const error = get('error')
117
+ if (error) {
118
+ const detail = get('error_description')
119
+ throw new OAuthError(`Sign-in was refused: ${detail ? `${error} (${detail})` : error}`)
120
+ }
121
+ if (get('state') !== expectedState)
122
+ throw new OAuthError('Sign-in was rejected: the response did not match this request (state)')
123
+ const code = get('code')
124
+ if (!code) throw new OAuthError('Sign-in returned no authorization code')
125
+ return { code }
126
+ }
127
+
128
+ /** The form fields of the code exchange. A public client sends the verifier and no client secret. */
129
+ export function tokenRequestFields(o: {
130
+ code: string
131
+ verifier: string
132
+ clientId: string
133
+ redirectUri: string
134
+ }): Record<string, string> {
135
+ return {
136
+ grant_type: 'authorization_code',
137
+ code: o.code,
138
+ redirect_uri: o.redirectUri,
139
+ client_id: o.clientId,
140
+ code_verifier: o.verifier,
141
+ }
142
+ }
143
+
144
+ export const tokenRequestBody = (o: Parameters<typeof tokenRequestFields>[0]): string =>
145
+ new URLSearchParams(tokenRequestFields(o)).toString()
146
+
147
+ export function parseTokenResponse(json: unknown): {
148
+ accessToken: string
149
+ expiresIn?: number
150
+ scope?: string
151
+ } {
152
+ const r = (json && typeof json === 'object' ? json : {}) as Record<string, unknown>
153
+ if (typeof r.error === 'string') {
154
+ const detail = typeof r.error_description === 'string' ? ` (${r.error_description})` : ''
155
+ throw new OAuthError(`The token endpoint refused the code: ${r.error}${detail}`)
156
+ }
157
+ if (typeof r.access_token !== 'string' || r.access_token === '')
158
+ throw new OAuthError('The token endpoint returned no access token')
159
+ return {
160
+ accessToken: r.access_token,
161
+ ...(typeof r.expires_in === 'number' ? { expiresIn: r.expires_in } : {}),
162
+ ...(typeof r.scope === 'string' ? { scope: r.scope } : {}),
163
+ }
164
+ }
package/src/runner/run.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createAsserter } from '../assert'
2
2
  import { ControlError, type Controls, describeControls, resolveControls } from '../controls'
3
3
  import type { Example, ExampleLoader, FileReader } from '../example'
4
+ import type { OAuthSettings } from '../oauth'
4
5
  import { MissingSecretsError, maskDeep } from '../secrets'
5
6
  import type { Sequence, StepCtx } from '../sequence'
6
7
  import {
@@ -40,6 +41,8 @@ export interface AuthProfile {
40
41
  token?: (res: ApiResponse<number, any>) => Session
41
42
  apply: { headers?: Record<string, string>; query?: Record<string, string> }
42
43
  retryOn401?: boolean
44
+ /** an interactive sign-in the UI can offer (authorization code with PKCE); the runner ignores it */
45
+ oauth?: OAuthSettings
43
46
  }
44
47
 
45
48
  export type RunEvent =
@@ -3,11 +3,43 @@
3
3
  * the CLI's `preview` and the browser viewer, so both read a spec the same way.
4
4
  */
5
5
 
6
+ import type { OAuthSettings } from './oauth'
7
+
6
8
  const isUrl = (s: string) => /^https?:\/\//i.test(s)
7
9
 
8
10
  export interface JsonAuthProfile {
9
11
  title: string
10
12
  apply: { headers?: Record<string, string>; query?: Record<string, string> }
13
+ /** present when the scheme has an authorization code flow the UI can sign in to */
14
+ oauth?: OAuthSettings
15
+ }
16
+
17
+ /** An OpenID Connect scheme: the discovery document says where to sign in. */
18
+ function discovery(s: any, base: string | undefined): OAuthSettings | undefined {
19
+ if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') return undefined
20
+ try {
21
+ return { openIdConnectUrl: new URL(s.openIdConnectUrl, base).toString() }
22
+ } catch {
23
+ return undefined
24
+ }
25
+ }
26
+
27
+ /** The authorization code flow of an OAuth scheme (OpenAPI 3 `flows`, or Swagger 2 `accessCode`). */
28
+ function codeFlow(s: any, base: string | undefined): OAuthSettings | undefined {
29
+ const flow = s.flows?.authorizationCode ?? (s.flow === 'accessCode' ? s : undefined)
30
+ if (!flow?.authorizationUrl || !flow?.tokenUrl) return undefined
31
+ const abs = (u: string) => {
32
+ try {
33
+ return new URL(u, base).toString()
34
+ } catch {
35
+ return u
36
+ }
37
+ }
38
+ return {
39
+ authorizationUrl: abs(flow.authorizationUrl),
40
+ tokenUrl: abs(flow.tokenUrl),
41
+ ...(flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}),
42
+ }
11
43
  }
12
44
 
13
45
  export interface SpecAuth {
@@ -28,7 +60,7 @@ const secretName = (scheme: string) =>
28
60
  * Turns a document's security schemes into auth profiles whose secrets are filled in at run time, so
29
61
  * previewing a protected API only needs the user to paste a key. OAuth-style schemes take an access token.
30
62
  */
31
- export function authFromSpec(api: any): SpecAuth {
63
+ export function authFromSpec(api: any, source?: string): SpecAuth {
32
64
  const schemes: Record<string, any> =
33
65
  api.components?.securitySchemes ?? api.securityDefinitions ?? {}
34
66
  const out: SpecAuth = { auth: {}, secrets: {} }
@@ -37,6 +69,7 @@ export function authFromSpec(api: any): SpecAuth {
37
69
  const ref = `{{secrets.${secret}}}`
38
70
  let apply: JsonAuthProfile['apply'] | undefined
39
71
  let title = name
72
+ let oauth: OAuthSettings | undefined
40
73
  if (s.type === 'apiKey') {
41
74
  const key = s.name as string
42
75
  if (s.in === 'header') apply = { headers: { [key]: ref } }
@@ -55,10 +88,12 @@ export function authFromSpec(api: any): SpecAuth {
55
88
  title = `${name} (basic auth, paste base64 of user:password)`
56
89
  } else if (s.type === 'oauth2' || s.type === 'openIdConnect') {
57
90
  apply = { headers: { authorization: `Bearer ${ref}` } }
58
- title = `${name} (OAuth, paste an access token)`
91
+ const base = source && isUrl(source) ? source : undefined
92
+ oauth = s.type === 'oauth2' ? codeFlow(s, base) : discovery(s, base)
93
+ title = `${name} (OAuth, ${oauth ? 'sign in or ' : ''}paste an access token)`
59
94
  }
60
95
  if (!apply) continue
61
- out.auth[name] = { title, apply }
96
+ out.auth[name] = { title, apply, ...(oauth ? { oauth } : {}) }
62
97
  out.secrets[secret] = {}
63
98
  }
64
99
  // the document's top-level `security` says which scheme most operations use
@@ -69,16 +104,112 @@ export function authFromSpec(api: any): SpecAuth {
69
104
  return out
70
105
  }
71
106
 
72
- /** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
73
- export function liveBaseUrl(api: any, source: string): string | undefined {
74
- const server = api.servers?.[0]?.url as string | undefined
75
- if (server) {
76
- if (isUrl(server)) return server.replace(/\/$/, '')
77
- if (isUrl(source)) return new URL(server, source).toString().replace(/\/$/, '')
78
- return undefined
107
+ /** One server a spec lists, as an environment the UI can switch to. */
108
+ export interface ServerEntry {
109
+ /** the environment name: `live` for the first, else a slug of its description or host */
110
+ name: string
111
+ url: string
112
+ description?: string
113
+ }
114
+
115
+ /** `live` plus this many minus one: a spec with a server per region should not bury the picker. */
116
+ export const MAX_SERVERS = 20
117
+
118
+ const slug = (s: string) =>
119
+ s
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9]+/g, '-')
122
+ .replace(/^-+|-+$/g, '')
123
+ .slice(0, 24)
124
+ .replace(/-+$/, '')
125
+
126
+ /** An absolute address for one `servers` entry: variables at their defaults, a relative URL against `source`. */
127
+ function resolveServer(entry: any, source: string | undefined): string | undefined {
128
+ if (typeof entry?.url !== 'string') return undefined
129
+ const url = entry.url
130
+ .replace(/\{([^}]+)\}/g, (whole: string, key: string) => {
131
+ const d = entry.variables?.[key]?.default
132
+ return d === undefined || d === null ? whole : String(d)
133
+ })
134
+ .trim()
135
+ if (/\{[^}]*\}/.test(url)) return undefined
136
+ if (isUrl(url)) return url.replace(/\/+$/, '')
137
+ if (source) {
138
+ try {
139
+ return new URL(url, source).toString().replace(/\/+$/, '')
140
+ } catch {
141
+ /* not an address */
142
+ }
79
143
  }
80
- if (api.host)
81
- return `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(/\/$/, '')
82
- if (isUrl(source)) return new URL(source).origin
83
144
  return undefined
84
145
  }
146
+
147
+ /**
148
+ * The servers a document lists, as environments. The first is `live` (what a single-server spec has always
149
+ * had); the rest are named from their description, else their host, and are always a valid environment name.
150
+ *
151
+ * `needsBaseUrl` is true when the first server is relative and `source` cannot resolve it: the person is
152
+ * asked for the address, and what they type is `live`. `omitted` counts usable servers beyond `MAX_SERVERS`.
153
+ */
154
+ export function openApiServers(
155
+ api: any,
156
+ source: string,
157
+ ): { servers: ServerEntry[]; needsBaseUrl: boolean; omitted: number } {
158
+ const src = isUrl(source) ? source : undefined
159
+ const listed: any[] = Array.isArray(api.servers) ? api.servers : []
160
+ const servers: ServerEntry[] = []
161
+ const describe = (e: any) =>
162
+ typeof e?.description === 'string' && e.description.trim() !== ''
163
+ ? { description: e.description.trim() as string }
164
+ : {}
165
+
166
+ if (listed.length === 0) {
167
+ // Swagger 2, or an OpenAPI 3 document that says nothing: the host, else wherever it was fetched from
168
+ if (api.host)
169
+ servers.push({
170
+ name: 'live',
171
+ url: `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(
172
+ /\/+$/,
173
+ '',
174
+ ),
175
+ })
176
+ else if (src) servers.push({ name: 'live', url: new URL(src).origin })
177
+ return { servers, needsBaseUrl: false, omitted: 0 }
178
+ }
179
+
180
+ const first = resolveServer(listed[0], src)
181
+ if (first) servers.push({ name: 'live', url: first, ...describe(listed[0]) })
182
+ const needsBaseUrl =
183
+ !first && typeof listed[0]?.url === 'string' && !/^https?:\/\//i.test(listed[0].url)
184
+
185
+ const taken = new Set(['mock', 'live'])
186
+ const seen = new Set(servers.map((x) => x.url))
187
+ let omitted = 0
188
+ for (const entry of listed.slice(1)) {
189
+ const url = resolveServer(entry, src)
190
+ if (!url || seen.has(url)) continue
191
+ if (servers.length >= MAX_SERVERS) {
192
+ omitted++
193
+ continue
194
+ }
195
+ seen.add(url)
196
+ const host = (() => {
197
+ try {
198
+ return new URL(url).host
199
+ } catch {
200
+ return ''
201
+ }
202
+ })()
203
+ const base = slug(describe(entry).description ?? '') || slug(host) || 'server'
204
+ let name = base
205
+ for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
206
+ taken.add(name)
207
+ servers.push({ name, url, ...describe(entry) })
208
+ }
209
+ return { servers, needsBaseUrl, omitted }
210
+ }
211
+
212
+ /** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
213
+ export function liveBaseUrl(api: any, source: string): string | undefined {
214
+ return openApiServers(api, source).servers.find((s) => s.name === 'live')?.url
215
+ }