@specific.dev/spectest 0.41.0 → 0.44.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/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/apple.d.ts +47 -0
- package/dist/components/apple.js +59 -0
- package/dist/components/emulate/service.d.ts +126 -0
- package/dist/components/emulate/service.js +153 -0
- package/dist/components/github.d.ts +52 -0
- package/dist/components/github.js +90 -0
- package/dist/components/google.d.ts +57 -0
- package/dist/components/google.js +92 -0
- package/dist/components/index.d.ts +6 -0
- package/dist/components/index.js +5 -0
- package/dist/components/microsoft.d.ts +55 -0
- package/dist/components/microsoft.js +76 -0
- package/dist/components/okta.d.ts +51 -0
- package/dist/components/okta.js +78 -0
- package/dist/daemon.js +76 -46
- package/dist/index.d.ts +55 -13
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/package.json +1 -1
- package/src/browser.ts +88 -33
- package/src/components/apple.ts +65 -0
- package/src/components/emulate/entry.mjs +244 -0
- package/src/components/emulate/service.ts +235 -0
- package/src/components/github.ts +96 -0
- package/src/components/google.ts +104 -0
- package/src/components/index.ts +14 -0
- package/src/components/microsoft.ts +89 -0
- package/src/components/okta.ts +93 -0
- package/src/daemon.ts +87 -39
- package/src/index.ts +55 -13
- package/src/mobile.ts +10 -5
- package/src/recorder.ts +10 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `apple()` — Apple, emulated inside the environment, answering at its real
|
|
2
|
+
// endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with Apple: discovery, the account picker, the
|
|
5
|
+
// token exchange, the refresh grant, and revocation, all on
|
|
6
|
+
// `appleid.apple.com`.
|
|
7
|
+
//
|
|
8
|
+
// Apple publishes **no userinfo endpoint** — the real one does not either,
|
|
9
|
+
// because for Apple the id_token is the whole profile. An app that wants to
|
|
10
|
+
// re-read an account uses the refresh grant, which is the only
|
|
11
|
+
// server-to-server call Apple offers.
|
|
12
|
+
|
|
13
|
+
import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
|
|
14
|
+
import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
|
|
15
|
+
|
|
16
|
+
export type AppleOptions = ProviderOptions;
|
|
17
|
+
|
|
18
|
+
function spec(users: ProviderUser[], opts: AppleOptions): ProviderSpec {
|
|
19
|
+
return {
|
|
20
|
+
name: "apple",
|
|
21
|
+
module: "@emulators/apple",
|
|
22
|
+
pluginExport: "applePlugin",
|
|
23
|
+
baseUrl: "https://appleid.apple.com",
|
|
24
|
+
// Every Apple endpoint is on this one host, so no path or discovery
|
|
25
|
+
// correction is needed.
|
|
26
|
+
hosts: ["appleid.apple.com"],
|
|
27
|
+
jwksPath: "^/auth/keys$",
|
|
28
|
+
fallbackUser: { login: users[0]!.email, id: 1, scopes: ["openid", "email", "name"] },
|
|
29
|
+
seed: {
|
|
30
|
+
users: users.map((u) => ({ email: u.email, name: u.name })),
|
|
31
|
+
// Apple authenticates the client with a signed assertion rather than
|
|
32
|
+
// a shared secret, so the registration carries a team id instead.
|
|
33
|
+
...oauthClients(opts.client, { team_id: "SPECTEST" }),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apple, answering at its real endpoints. Drop into
|
|
40
|
+
* `environment.services`:
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { apple } from "@specific.dev/spectest/components";
|
|
44
|
+
*
|
|
45
|
+
* services: {
|
|
46
|
+
* apple: apple({
|
|
47
|
+
* users: [{ email: "alice@example.com", name: "Alice Example" }],
|
|
48
|
+
* client: {
|
|
49
|
+
* clientId: "com.example.app",
|
|
50
|
+
* clientSecret: "client-assertion",
|
|
51
|
+
* redirectUris: ["https://app.test/callback/apple"],
|
|
52
|
+
* },
|
|
53
|
+
* }),
|
|
54
|
+
* app: { …, dependsOn: ["apple"] },
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* The app keeps its production configuration — it sends a browser to
|
|
59
|
+
* `https://appleid.apple.com/auth/authorize` and exchanges the code at
|
|
60
|
+
* `https://appleid.apple.com/auth/token`.
|
|
61
|
+
*/
|
|
62
|
+
export function apple(opts: AppleOptions = {}) {
|
|
63
|
+
const users = resolveUsers("apple", opts.users);
|
|
64
|
+
return emulatorService(spec(users, opts));
|
|
65
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The `socialAuth()` container's entry point.
|
|
2
|
+
//
|
|
3
|
+
// It runs one emulator per configured provider (the `@emulators/*` packages
|
|
4
|
+
// from vercel-labs/emulate) and puts all of them behind ONE port, routed by
|
|
5
|
+
// `Host` header. That is what lets the component answer at the providers'
|
|
6
|
+
// real endpoints: the daemon owns DNS and TLS for those names and proxies
|
|
7
|
+
// every one of them here.
|
|
8
|
+
//
|
|
9
|
+
// The engine is generic. Everything provider-specific — which hosts to
|
|
10
|
+
// claim, which paths differ from the real ones, which discovery values to
|
|
11
|
+
// correct — arrives as JSON in SPECTEST_AUTH_CONFIG, built by
|
|
12
|
+
// `../social-auth.ts`. Add a provider there, not here.
|
|
13
|
+
//
|
|
14
|
+
// Four things happen to a response on the way out:
|
|
15
|
+
//
|
|
16
|
+
// * discovery — the emulator advertises every endpoint on its own single
|
|
17
|
+
// origin. Real providers spread them over several hosts. We merge the
|
|
18
|
+
// real URLs in.
|
|
19
|
+
// * JWKS — we serve one RSA public key, for every provider.
|
|
20
|
+
// * id_token — we re-issue it: same claims, RS256, our key, and a
|
|
21
|
+
// corrected `iss` where the emulator cannot know it. This is one step
|
|
22
|
+
// that fixes three faults. The Google emulator signs HS256 and serves
|
|
23
|
+
// an empty JWKS, so no client can verify its token. The Microsoft one
|
|
24
|
+
// cannot put the tenant in the issuer, because it does not know it.
|
|
25
|
+
// And any future provider whose issuer does not match its base URL is
|
|
26
|
+
// covered in advance. No signature check is needed on the way in: the
|
|
27
|
+
// token comes from an in-process function call, not from the network.
|
|
28
|
+
// * the account picker page — each button gets a `data-testid`, so a
|
|
29
|
+
// browser test has a stable locator.
|
|
30
|
+
//
|
|
31
|
+
// Every request is logged. Per-case service logs reach the dashboard, so
|
|
32
|
+
// the whole OAuth exchange is readable on the case page with no helper API.
|
|
33
|
+
|
|
34
|
+
import { createServer } from "@emulators/core";
|
|
35
|
+
import { SignJWT, decodeJwt, exportJWK } from "jose";
|
|
36
|
+
|
|
37
|
+
const CONFIG = JSON.parse(process.env.SPECTEST_AUTH_CONFIG ?? "{}");
|
|
38
|
+
const PORT = Number(CONFIG.port ?? 4000);
|
|
39
|
+
/** Key id published in the JWKS and stamped on every re-issued id_token. */
|
|
40
|
+
const KID = "spectest-social-auth";
|
|
41
|
+
|
|
42
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
43
|
+
// Signing key
|
|
44
|
+
//
|
|
45
|
+
// Generated once, at boot — which is before the warm-template snapshot, so
|
|
46
|
+
// every fork of this environment holds the same key and a token minted in a
|
|
47
|
+
// parent stays valid in a child. Never generate it later: after a fork the
|
|
48
|
+
// guest RNG is frozen, and sibling forks would produce identical keys.
|
|
49
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const KEY_PAIR = await crypto.subtle.generateKey(
|
|
52
|
+
{
|
|
53
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
54
|
+
modulusLength: 2048,
|
|
55
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
56
|
+
hash: "SHA-256",
|
|
57
|
+
},
|
|
58
|
+
true,
|
|
59
|
+
["sign", "verify"],
|
|
60
|
+
);
|
|
61
|
+
const JWKS = {
|
|
62
|
+
keys: [{ ...(await exportJWK(KEY_PAIR.publicKey)), kid: KID, use: "sig", alg: "RS256" }],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// Providers
|
|
67
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** host → runtime record. One emulator can claim several hosts. */
|
|
70
|
+
const BY_HOST = new Map();
|
|
71
|
+
|
|
72
|
+
for (const spec of CONFIG.providers ?? []) {
|
|
73
|
+
const mod = await import(spec.module);
|
|
74
|
+
const plugin = mod[spec.pluginExport];
|
|
75
|
+
if (!plugin) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`social-auth: ${spec.module} has no export ${spec.pluginExport}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `port` only feeds the emulator's default base URL, which we always
|
|
82
|
+
// override — nothing listens on it. Each emulator is a fetch handler.
|
|
83
|
+
const { app, store, webhooks } = createServer(plugin, {
|
|
84
|
+
port: PORT,
|
|
85
|
+
baseUrl: spec.baseUrl,
|
|
86
|
+
fallbackUser: spec.fallbackUser,
|
|
87
|
+
});
|
|
88
|
+
plugin.seed?.(store, spec.baseUrl);
|
|
89
|
+
if (spec.seed && mod.seedFromConfig) {
|
|
90
|
+
mod.seedFromConfig(store, spec.baseUrl, spec.seed, webhooks);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const runtime = {
|
|
94
|
+
name: spec.name,
|
|
95
|
+
baseUrl: spec.baseUrl,
|
|
96
|
+
oidc: spec.oidc !== false,
|
|
97
|
+
issuer: spec.issuer,
|
|
98
|
+
discovery: spec.discovery ?? {},
|
|
99
|
+
jwksPath: spec.jwksPath ? new RegExp(spec.jwksPath) : null,
|
|
100
|
+
aliases: spec.aliases,
|
|
101
|
+
rewrites: (spec.rewrites ?? []).map((r) => ({ re: new RegExp(r.from), to: r.to })),
|
|
102
|
+
fetch: app.fetch,
|
|
103
|
+
};
|
|
104
|
+
for (const host of spec.hosts) BY_HOST.set(host.toLowerCase(), runtime);
|
|
105
|
+
console.log(`[social-auth] ${spec.name} → ${spec.hosts.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
109
|
+
// Response rewriting
|
|
110
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const DISCOVERY_RE = /\/\.well-known\/openid-configuration$/;
|
|
113
|
+
|
|
114
|
+
/** Correct the discovery document: real hosts per endpoint, and RS256,
|
|
115
|
+
* which is what we actually sign with after `reissueIdToken`. */
|
|
116
|
+
async function patchDiscovery(res, provider) {
|
|
117
|
+
const doc = await res.json();
|
|
118
|
+
const patched = {
|
|
119
|
+
...doc,
|
|
120
|
+
...provider.discovery,
|
|
121
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
122
|
+
};
|
|
123
|
+
return json(patched, res.status);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Re-issue the id_token on a token response. Claims pass through
|
|
127
|
+
* unchanged except `iss`, which only a provider that cannot derive it
|
|
128
|
+
* from its base URL overrides. */
|
|
129
|
+
async function patchTokenResponse(res, provider) {
|
|
130
|
+
const body = await res.json();
|
|
131
|
+
if (typeof body.id_token !== "string") return json(body, res.status);
|
|
132
|
+
|
|
133
|
+
const claims = decodeJwt(body.id_token);
|
|
134
|
+
if (provider.issuer) claims.iss = provider.issuer;
|
|
135
|
+
body.id_token = await new SignJWT(claims)
|
|
136
|
+
.setProtectedHeader({ alg: "RS256", kid: KID, typ: "JWT" })
|
|
137
|
+
.sign(KEY_PAIR.privateKey);
|
|
138
|
+
return json(body, res.status);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Give every account button on the picker page a stable test id, and mark
|
|
142
|
+
* the page itself. The hidden field that identifies the account is named
|
|
143
|
+
* differently per provider, and does not always hold the address — GitHub
|
|
144
|
+
* identifies an account by its login — so `aliases` maps it back. The test
|
|
145
|
+
* id is the account's email on every provider. */
|
|
146
|
+
async function patchPickerPage(res, provider) {
|
|
147
|
+
let html = await res.text();
|
|
148
|
+
html = html.replace(/<form class="user-form"[\s\S]*?<\/form>/g, (form) => {
|
|
149
|
+
const id = /<input type="hidden" name="(?:email|login|sub|username|user_ref)" value="([^"]*)"/.exec(form);
|
|
150
|
+
if (!id) return form;
|
|
151
|
+
const account = provider.aliases?.[id[1]] ?? id[1];
|
|
152
|
+
return form.replace(
|
|
153
|
+
"<button type=\"submit\"",
|
|
154
|
+
`<button type="submit" data-testid="spectest-user-${account}"`,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
html = html.replace("<body", '<body data-spectest-signin="1"');
|
|
158
|
+
return new Response(html, { status: res.status, headers: res.headers });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function json(value, status) {
|
|
162
|
+
return new Response(JSON.stringify(value), {
|
|
163
|
+
status,
|
|
164
|
+
headers: { "content-type": "application/json" },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// Server
|
|
170
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
Bun.serve({
|
|
173
|
+
port: PORT,
|
|
174
|
+
// The default 10s kills nothing here, but an authorize page fetched by a
|
|
175
|
+
// cold browser can be slower than it looks. Ingress uses the same margin.
|
|
176
|
+
idleTimeout: 60,
|
|
177
|
+
async fetch(req) {
|
|
178
|
+
const url = new URL(req.url);
|
|
179
|
+
|
|
180
|
+
// Answered on any Host, because the ready check probes the container's
|
|
181
|
+
// own IP and never sends a provider name.
|
|
182
|
+
if (url.pathname === "/healthz") return new Response("ok\n");
|
|
183
|
+
|
|
184
|
+
// The daemon reverse-proxies us and rewrites `Host` to the service-net
|
|
185
|
+
// name, so the provider the client asked for only survives in
|
|
186
|
+
// `X-Forwarded-Host`. Read that first; `Host` covers a direct call.
|
|
187
|
+
const host = (req.headers.get("x-forwarded-host") ?? req.headers.get("host") ?? "")
|
|
188
|
+
.toLowerCase()
|
|
189
|
+
.replace(/:\d+$/, "");
|
|
190
|
+
const provider = BY_HOST.get(host);
|
|
191
|
+
if (!provider) {
|
|
192
|
+
return new Response(
|
|
193
|
+
`spectest social-auth: no provider claims Host=${JSON.stringify(host)}\n` +
|
|
194
|
+
`claimed hosts: ${[...BY_HOST.keys()].join(", ")}\n`,
|
|
195
|
+
{ status: 404, headers: { "content-type": "text/plain" } },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Real path → the path this emulator serves it on. Identity by default,
|
|
200
|
+
// which is also what carries the emulator's own internal endpoints (the
|
|
201
|
+
// picker's POST target) through untouched.
|
|
202
|
+
let path = url.pathname;
|
|
203
|
+
for (const rule of provider.rewrites) {
|
|
204
|
+
if (rule.re.test(path)) {
|
|
205
|
+
path = path.replace(rule.re, rule.to);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let res;
|
|
211
|
+
if (provider.oidc && provider.jwksPath?.test(path)) {
|
|
212
|
+
// Our key, never the emulator's — we re-sign every id_token.
|
|
213
|
+
res = json(JWKS, 200);
|
|
214
|
+
} else {
|
|
215
|
+
const body =
|
|
216
|
+
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
|
217
|
+
// Restore the Host the client used, so an emulator that reads it sees
|
|
218
|
+
// the provider rather than our container.
|
|
219
|
+
const headers = new Headers(req.headers);
|
|
220
|
+
headers.set("host", host);
|
|
221
|
+
const inner = new Request(new URL(path + url.search, provider.baseUrl), {
|
|
222
|
+
method: req.method,
|
|
223
|
+
headers,
|
|
224
|
+
body,
|
|
225
|
+
});
|
|
226
|
+
res = await provider.fetch(inner);
|
|
227
|
+
|
|
228
|
+
const type = res.headers.get("content-type") ?? "";
|
|
229
|
+
if (type.includes("json")) {
|
|
230
|
+
if (DISCOVERY_RE.test(path)) res = await patchDiscovery(res, provider);
|
|
231
|
+
else if (provider.oidc) res = await patchTokenResponse(res, provider);
|
|
232
|
+
} else if (type.includes("text/html")) {
|
|
233
|
+
res = await patchPickerPage(res, provider);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(
|
|
238
|
+
`[social-auth] ${provider.name} ${req.method} ${host}${url.pathname} → ${res.status}`,
|
|
239
|
+
);
|
|
240
|
+
return res;
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
console.log(`[social-auth] listening on :${PORT}`);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Shared runtime behind the third-party provider components — `google()`,
|
|
2
|
+
// `github()`, `apple()`, `microsoft()`, `okta()`.
|
|
3
|
+
//
|
|
4
|
+
// Each of those is one container answering at that provider's **real**
|
|
5
|
+
// endpoints: the component claims the provider's domains, so DNS, a leaf
|
|
6
|
+
// certificate from the in-VM root CA, and a reverse proxy all point at it.
|
|
7
|
+
// The app under test keeps its production configuration and never learns it
|
|
8
|
+
// is under test.
|
|
9
|
+
//
|
|
10
|
+
// A provider component is therefore a table, not a program. It names the
|
|
11
|
+
// hosts it claims, the emulator package behind it, the handful of paths
|
|
12
|
+
// where that emulator differs from the real service, and how to seed it.
|
|
13
|
+
// Everything else lives here and in `entry.mjs`.
|
|
14
|
+
//
|
|
15
|
+
// ── Internal notes (deliberately not in the user-facing docs) ─────────────
|
|
16
|
+
//
|
|
17
|
+
// The emulators come from vercel-labs/emulate (Apache-2.0), used through
|
|
18
|
+
// their published `@emulators/*` packages and pinned. They are NOT SDK
|
|
19
|
+
// dependencies: they install into the image below. That matters —
|
|
20
|
+
// `sdk/package.json` is in the base-snapshot discriminator, so a dependency
|
|
21
|
+
// here would force a base rebuild and one cold start for every project on
|
|
22
|
+
// the box, for components most projects never use.
|
|
23
|
+
//
|
|
24
|
+
// Each emulator serves every one of its endpoints on ONE origin. Real
|
|
25
|
+
// providers spread them over several hosts, and a few paths differ.
|
|
26
|
+
// `entry.mjs` routes by Host header, applies the per-provider path table,
|
|
27
|
+
// and corrects the discovery document. It also re-issues every id_token
|
|
28
|
+
// with one RSA key it generates at boot, and serves that key at the
|
|
29
|
+
// provider's real JWKS URL. That single step covers three separate faults:
|
|
30
|
+
// the Google emulator signs HS256 and publishes an empty JWKS (so nothing
|
|
31
|
+
// can verify its token), the Microsoft one cannot put the tenant in its
|
|
32
|
+
// issuer, and any future provider whose issuer is not its base URL is
|
|
33
|
+
// handled in advance.
|
|
34
|
+
//
|
|
35
|
+
// The Dockerfile is deliberately CONSTANT — every provider package is
|
|
36
|
+
// installed whether or not this component is the one using it. Two reasons:
|
|
37
|
+
// one image is shared by every provider component and every project on the
|
|
38
|
+
// box, so the layer cache is hit; and the daemon dedupes identical
|
|
39
|
+
// dockerfile builds inside one bootstrap, so five provider services in one
|
|
40
|
+
// environment cost one build, not five.
|
|
41
|
+
//
|
|
42
|
+
// `entry.mjs` ships as an asset next to this file, read at load time and
|
|
43
|
+
// injected with `files`. It is not a `.ts` string constant: it imports
|
|
44
|
+
// `@emulators/*`, which the SDK does not depend on, so `tsc` could not
|
|
45
|
+
// check it anyway, and 200 lines inside a template literal is worse to
|
|
46
|
+
// maintain. `.mjs` also keeps it out of `tsconfig`'s `include` with no
|
|
47
|
+
// exclude rule, and `files: ["src"]` in the manifest still ships it.
|
|
48
|
+
|
|
49
|
+
import { readFileSync } from "node:fs";
|
|
50
|
+
import path from "node:path";
|
|
51
|
+
import { fileURLToPath } from "node:url";
|
|
52
|
+
|
|
53
|
+
import type { ServiceDefinition } from "../../index.js";
|
|
54
|
+
import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../../index.js";
|
|
55
|
+
|
|
56
|
+
/** Base image, pinned. Bun runs the emulators' ESM directly. */
|
|
57
|
+
const IMAGE_BASE = "oven/bun:1.3.14-alpine";
|
|
58
|
+
/** Emulator package version, pinned rather than floated. */
|
|
59
|
+
const EMULATE_VERSION = "0.9.0";
|
|
60
|
+
/** The port the provider is served on. Callers reach it through the
|
|
61
|
+
* provider's real hostnames, never through this port. */
|
|
62
|
+
const PORT = 4000;
|
|
63
|
+
/** Ready-check budget. The container boots in about a second; the slow part
|
|
64
|
+
* is a cold image build on a machine that has never run it. */
|
|
65
|
+
const READY_TIMEOUT_SECS = 120;
|
|
66
|
+
|
|
67
|
+
/** A person who can sign in. Seeded into the provider's account picker. */
|
|
68
|
+
export interface ProviderUser {
|
|
69
|
+
/** Primary address. Also the account's test id on the picker page. */
|
|
70
|
+
email: string;
|
|
71
|
+
/** Display name. */
|
|
72
|
+
name?: string;
|
|
73
|
+
/** Avatar URL, passed through to the profile claims. */
|
|
74
|
+
picture?: string;
|
|
75
|
+
/** GitHub username. Defaults to the local part of `email`. */
|
|
76
|
+
login?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** An OAuth client, as registered with the real provider. Declaring one
|
|
80
|
+
* makes the environment **stricter**: `clientId`, `clientSecret` and
|
|
81
|
+
* `redirectUris` are then all validated, exactly as in production. Omit it
|
|
82
|
+
* and any client is accepted. */
|
|
83
|
+
export interface OAuthClient {
|
|
84
|
+
clientId: string;
|
|
85
|
+
clientSecret: string;
|
|
86
|
+
/** Callback URLs the app is allowed to return to. A request to any other
|
|
87
|
+
* one is refused, which is what makes a misconfigured callback fail here
|
|
88
|
+
* rather than in production. */
|
|
89
|
+
redirectUris?: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The accounts every provider offers when a component declares none.
|
|
94
|
+
*
|
|
95
|
+
* Two, not one: a test that asserts it signed in as Alice only means
|
|
96
|
+
* something if the provider had somebody else to return. Both carry a
|
|
97
|
+
* `login`, so GitHub gets a handle without the caller supplying one.
|
|
98
|
+
*
|
|
99
|
+
* `example.com` is reserved for exactly this (RFC 2606), so no default
|
|
100
|
+
* account can ever collide with a real address.
|
|
101
|
+
*/
|
|
102
|
+
export const DEFAULT_USERS: readonly ProviderUser[] = [
|
|
103
|
+
{ email: "alice@example.com", name: "Alice Example", login: "alice" },
|
|
104
|
+
{ email: "bob@example.com", name: "Bob Example", login: "bob" },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** Options every provider component takes. */
|
|
108
|
+
export interface ProviderOptions {
|
|
109
|
+
/**
|
|
110
|
+
* The accounts offered on the provider's picker page. Defaults to
|
|
111
|
+
* **Alice Example** (`alice@example.com`) and **Bob Example**
|
|
112
|
+
* (`bob@example.com`) — enough to sign in as somebody, and to tell two
|
|
113
|
+
* people apart, without declaring anything.
|
|
114
|
+
*/
|
|
115
|
+
users?: ProviderUser[];
|
|
116
|
+
/** The app's OAuth client. Omit it and any client id, secret and callback
|
|
117
|
+
* URL are accepted. */
|
|
118
|
+
client?: OAuthClient;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What `entry.mjs` consumes. Everything provider-specific lives in the
|
|
122
|
+
* per-provider module; the script itself is generic. */
|
|
123
|
+
export interface ProviderSpec {
|
|
124
|
+
name: string;
|
|
125
|
+
module: string;
|
|
126
|
+
pluginExport: string;
|
|
127
|
+
/** Base URL the emulator advertises — the provider's real sign-in host. */
|
|
128
|
+
baseUrl: string;
|
|
129
|
+
/** Every hostname routed to this emulator. */
|
|
130
|
+
hosts: string[];
|
|
131
|
+
/** False for a plain OAuth 2.0 provider with no id_token (GitHub). */
|
|
132
|
+
oidc?: boolean;
|
|
133
|
+
/** Issuer to stamp on the re-issued id_token, where the emulator cannot
|
|
134
|
+
* derive it from `baseUrl`. */
|
|
135
|
+
issuer?: string;
|
|
136
|
+
/** Values merged over the emulator's discovery document. */
|
|
137
|
+
discovery?: Record<string, string>;
|
|
138
|
+
/** Path (as a regex source) answered with our own JWKS. */
|
|
139
|
+
jwksPath?: string;
|
|
140
|
+
/** Picker-page identifier → the account's email, where the provider
|
|
141
|
+
* identifies an account by something else. Keeps the account's test id
|
|
142
|
+
* the same on every provider. */
|
|
143
|
+
aliases?: Record<string, string>;
|
|
144
|
+
/** Real path → the path this emulator serves it on. Identity elsewhere. */
|
|
145
|
+
rewrites?: { from: string; to: string }[];
|
|
146
|
+
fallbackUser?: { login: string; id: number; scopes: string[] };
|
|
147
|
+
seed?: Record<string, unknown>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The entry script, read from disk next to this module. */
|
|
151
|
+
const ENTRY_SCRIPT: string = (() => {
|
|
152
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
153
|
+
return readFileSync(path.join(here, "entry.mjs"), "utf8");
|
|
154
|
+
})();
|
|
155
|
+
|
|
156
|
+
const DOCKERFILE = `
|
|
157
|
+
FROM ${IMAGE_BASE}
|
|
158
|
+
WORKDIR /srv
|
|
159
|
+
RUN echo '{"name":"spectest-provider","private":true}' > package.json \\
|
|
160
|
+
&& bun add \\
|
|
161
|
+
@emulators/core@${EMULATE_VERSION} \\
|
|
162
|
+
@emulators/google@${EMULATE_VERSION} \\
|
|
163
|
+
@emulators/github@${EMULATE_VERSION} \\
|
|
164
|
+
@emulators/apple@${EMULATE_VERSION} \\
|
|
165
|
+
@emulators/microsoft@${EMULATE_VERSION} \\
|
|
166
|
+
@emulators/okta@${EMULATE_VERSION} \\
|
|
167
|
+
jose@6
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
/** The service definition for one provider: the container, plus the claim
|
|
171
|
+
* on its domains (one certificate carrying every hostname as a SAN, and a
|
|
172
|
+
* proxy route each). One `certificate(...)` rather than per-host `tls`
|
|
173
|
+
* entries, for the reason `aws()` gives — `tls` mints a separate leaf per
|
|
174
|
+
* entry, and a provider can claim four names. */
|
|
175
|
+
export function emulatorService(spec: ProviderSpec) {
|
|
176
|
+
const service = {
|
|
177
|
+
image: { type: "dockerfile" as const, content: DOCKERFILE },
|
|
178
|
+
command: "bun /srv/auth.mjs",
|
|
179
|
+
files: [{ path: "/srv/auth.mjs", content: ENTRY_SCRIPT }],
|
|
180
|
+
env: { SPECTEST_AUTH_CONFIG: JSON.stringify({ port: PORT, providers: [spec] }) },
|
|
181
|
+
ports: [PORT],
|
|
182
|
+
readyCheck: {
|
|
183
|
+
type: "http" as const,
|
|
184
|
+
port: PORT,
|
|
185
|
+
path: "/healthz",
|
|
186
|
+
timeoutSecs: READY_TIMEOUT_SECS,
|
|
187
|
+
},
|
|
188
|
+
} satisfies ServiceDefinition;
|
|
189
|
+
|
|
190
|
+
return provides(service, [
|
|
191
|
+
certificate(spec.hosts),
|
|
192
|
+
...spec.hosts.map((hostname) => proxy(hostname, { service: SELF_SERVICE_TOKEN, port: PORT })),
|
|
193
|
+
]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Seeding helpers, shared by the provider modules ───────────────────────
|
|
197
|
+
|
|
198
|
+
/** The accounts to seed: what the caller declared, or {@link DEFAULT_USERS}.
|
|
199
|
+
* An explicitly empty list is a mistake, not a request for none — a
|
|
200
|
+
* provider with no accounts can never sign anyone in. */
|
|
201
|
+
export function resolveUsers(component: string, users: ProviderUser[] | undefined): ProviderUser[] {
|
|
202
|
+
if (users && users.length === 0) {
|
|
203
|
+
throw new Error(`${component}(): \`users\` is empty — omit it for the default accounts`);
|
|
204
|
+
}
|
|
205
|
+
return users ?? [...DEFAULT_USERS];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function localPart(email: string): string {
|
|
209
|
+
return email.split("@")[0] ?? email;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function givenName(user: ProviderUser): string | undefined {
|
|
213
|
+
return user.name?.split(" ")[0];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function familyName(user: ProviderUser): string | undefined {
|
|
217
|
+
const parts = user.name?.split(" ") ?? [];
|
|
218
|
+
return parts.length > 1 ? parts.slice(1).join(" ") : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The `oauth_clients` seed entry every provider but GitHub uses. */
|
|
222
|
+
export function oauthClients(client: OAuthClient | undefined, extra: Record<string, unknown> = {}) {
|
|
223
|
+
if (!client) return {};
|
|
224
|
+
return {
|
|
225
|
+
oauth_clients: [
|
|
226
|
+
{
|
|
227
|
+
client_id: client.clientId,
|
|
228
|
+
client_secret: client.clientSecret,
|
|
229
|
+
name: "spectest",
|
|
230
|
+
redirect_uris: client.redirectUris ?? [],
|
|
231
|
+
...extra,
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `github()` — GitHub, emulated inside the environment, answering at its
|
|
2
|
+
// real endpoints.
|
|
3
|
+
//
|
|
4
|
+
// Today that is Sign in with GitHub — the authorize page, the token
|
|
5
|
+
// exchange, and the profile reads every GitHub sign-in makes (`/user`,
|
|
6
|
+
// `/user/emails`). The component is named for the provider rather than for
|
|
7
|
+
// the feature: the backing emulator carries most of the REST API — repos,
|
|
8
|
+
// issues, pull requests, checks, webhooks — and reaching it is a matter of
|
|
9
|
+
// what this component chooses to expose, not of new plumbing.
|
|
10
|
+
//
|
|
11
|
+
// Landmine for whoever extends this: the claim is env-wide. `github.com`
|
|
12
|
+
// and `api.github.com` stop reaching the internet for every container in
|
|
13
|
+
// the environment, so anything else that talks to GitHub — a build step
|
|
14
|
+
// that clones a repository, an unrelated API call — reaches the emulator
|
|
15
|
+
// and gets a 404 outside the surface it serves.
|
|
16
|
+
|
|
17
|
+
import type { ProviderOptions, ProviderSpec, ProviderUser } from "./emulate/service.js";
|
|
18
|
+
import { emulatorService, localPart, resolveUsers } from "./emulate/service.js";
|
|
19
|
+
|
|
20
|
+
export type GithubOptions = ProviderOptions;
|
|
21
|
+
|
|
22
|
+
function spec(users: ProviderUser[], opts: GithubOptions): ProviderSpec {
|
|
23
|
+
const client = opts.client;
|
|
24
|
+
return {
|
|
25
|
+
name: "github",
|
|
26
|
+
module: "@emulators/github",
|
|
27
|
+
pluginExport: "githubPlugin",
|
|
28
|
+
baseUrl: "https://github.com",
|
|
29
|
+
// api.github.com cannot be avoided: every GitHub sign-in reads the
|
|
30
|
+
// profile from /user and the addresses from /user/emails.
|
|
31
|
+
hosts: ["github.com", "api.github.com"],
|
|
32
|
+
// OAuth 2.0, not OIDC — there is no id_token to re-issue and no JWKS.
|
|
33
|
+
oidc: false,
|
|
34
|
+
// GitHub identifies an account by login, not by address.
|
|
35
|
+
aliases: Object.fromEntries(users.map((u) => [u.login ?? localPart(u.email), u.email])),
|
|
36
|
+
fallbackUser: {
|
|
37
|
+
login: users[0]!.login ?? localPart(users[0]!.email),
|
|
38
|
+
id: 1,
|
|
39
|
+
scopes: ["user", "user:email"],
|
|
40
|
+
},
|
|
41
|
+
seed: {
|
|
42
|
+
users: users.map((u) => ({
|
|
43
|
+
login: u.login ?? localPart(u.email),
|
|
44
|
+
name: u.name,
|
|
45
|
+
email: u.email,
|
|
46
|
+
})),
|
|
47
|
+
// GitHub calls them oauth_apps, and its own field is `oauth_apps`.
|
|
48
|
+
...(client
|
|
49
|
+
? {
|
|
50
|
+
oauth_apps: [
|
|
51
|
+
{
|
|
52
|
+
client_id: client.clientId,
|
|
53
|
+
client_secret: client.clientSecret,
|
|
54
|
+
name: "spectest",
|
|
55
|
+
redirect_uris: client.redirectUris ?? [],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* GitHub, answering at its real endpoints. Drop into
|
|
66
|
+
* `environment.services`:
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* import { github } from "@specific.dev/spectest/components";
|
|
70
|
+
*
|
|
71
|
+
* services: {
|
|
72
|
+
* github: github({
|
|
73
|
+
* users: [{ email: "bob@example.com", name: "Bob Example", login: "bob" }],
|
|
74
|
+
* client: {
|
|
75
|
+
* clientId: "Iv1.example",
|
|
76
|
+
* clientSecret: "ghs_example",
|
|
77
|
+
* redirectUris: ["https://app.test/callback/github"],
|
|
78
|
+
* },
|
|
79
|
+
* }),
|
|
80
|
+
* app: { …, dependsOn: ["github"] },
|
|
81
|
+
* }
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* The app keeps its production configuration — it sends a browser to
|
|
85
|
+
* `https://github.com/login/oauth/authorize`, exchanges the code at
|
|
86
|
+
* `https://github.com/login/oauth/access_token`, and reads the account from
|
|
87
|
+
* `https://api.github.com/user`.
|
|
88
|
+
*
|
|
89
|
+
* The account's test id on the picker page is its **email**, even though
|
|
90
|
+
* GitHub identifies it by login — so a test reads the same whichever
|
|
91
|
+
* provider it drives.
|
|
92
|
+
*/
|
|
93
|
+
export function github(opts: GithubOptions = {}) {
|
|
94
|
+
const users = resolveUsers("github", opts.users);
|
|
95
|
+
return emulatorService(spec(users, opts));
|
|
96
|
+
}
|