@specific.dev/spectest 0.43.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.
@@ -6,4 +6,10 @@ export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
6
6
  export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
7
7
  export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
8
8
  export { aws, type AwsOptions, type LambdaOptions, } from "./aws.js";
9
+ export { type ProviderOptions, type ProviderUser, type OAuthClient, } from "./emulate/service.js";
10
+ export { google, type GoogleOptions } from "./google.js";
11
+ export { github, type GithubOptions } from "./github.js";
12
+ export { apple, type AppleOptions } from "./apple.js";
13
+ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
14
+ export { okta, type OktaOptions } from "./okta.js";
9
15
  export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
@@ -16,4 +16,9 @@ export { expo, } from "./expo.js";
16
16
  export { supabase, } from "./supabase.js";
17
17
  export { email, } from "./email.js";
18
18
  export { aws, } from "./aws.js";
19
+ export { google } from "./google.js";
20
+ export { github } from "./github.js";
21
+ export { apple } from "./apple.js";
22
+ export { microsoft } from "./microsoft.js";
23
+ export { okta } from "./okta.js";
19
24
  export { replayFake, } from "./replayFake.js";
@@ -0,0 +1,55 @@
1
+ import type { ProviderOptions } from "./emulate/service.js";
2
+ export interface MicrosoftOptions extends ProviderOptions {
3
+ /**
4
+ * Directory (tenant) the app signs in against — whatever its authority
5
+ * URL uses. It appears in the issuer and in every endpoint path, so a
6
+ * client that checks the issuer sees what it expects. Default `"common"`.
7
+ */
8
+ tenantId?: string;
9
+ }
10
+ /**
11
+ * Microsoft, answering at its real endpoints. Drop into
12
+ * `environment.services`:
13
+ *
14
+ * ```ts
15
+ * import { microsoft } from "@specific.dev/spectest/components";
16
+ *
17
+ * services: {
18
+ * microsoft: microsoft({
19
+ * tenantId: "11111111-2222-3333-4444-555555555555",
20
+ * users: [{ email: "bob@example.com", name: "Bob Example" }],
21
+ * client: {
22
+ * clientId: "6f1a2b3c-…",
23
+ * clientSecret: "secret",
24
+ * redirectUris: ["https://app.test/callback/microsoft"],
25
+ * },
26
+ * }),
27
+ * app: { …, dependsOn: ["microsoft"] },
28
+ * }
29
+ * ```
30
+ *
31
+ * The app keeps its production configuration — discovery at
32
+ * `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
33
+ * and the account read back from `https://graph.microsoft.com/v1.0/me`.
34
+ */
35
+ export declare function microsoft(opts?: MicrosoftOptions): {
36
+ image: {
37
+ type: "dockerfile";
38
+ content: string;
39
+ };
40
+ command: string;
41
+ files: {
42
+ path: string;
43
+ content: string;
44
+ }[];
45
+ env: {
46
+ SPECTEST_AUTH_CONFIG: string;
47
+ };
48
+ ports: number[];
49
+ readyCheck: {
50
+ type: "http";
51
+ port: number;
52
+ path: string;
53
+ timeoutSecs: number;
54
+ };
55
+ };
@@ -0,0 +1,76 @@
1
+ // `microsoft()` — Microsoft, emulated inside the environment, answering at
2
+ // its real endpoints.
3
+ //
4
+ // Today that is Sign in with Microsoft (Entra ID) plus the part of Graph an
5
+ // app reads straight after: discovery, the account picker, the token
6
+ // exchange, `/oidc/userinfo` and `/v1.0/me`. The component is named for the
7
+ // provider rather than for the feature — Graph is already claimed, and
8
+ // widening what it serves is a table entry.
9
+ import { emulatorService, oauthClients, resolveUsers } from "./emulate/service.js";
10
+ function spec(users, opts) {
11
+ const authority = `https://login.microsoftonline.com/${opts.tenantId || "common"}`;
12
+ return {
13
+ name: "microsoft",
14
+ module: "@emulators/microsoft",
15
+ pluginExport: "microsoftPlugin",
16
+ baseUrl: "https://login.microsoftonline.com",
17
+ hosts: ["login.microsoftonline.com", "graph.microsoft.com"],
18
+ // The emulator has no tenant, so its issuer is the bare host. A client
19
+ // that checks the issuer against the authority it configured would
20
+ // reject that; the re-issue step corrects it.
21
+ issuer: `${authority}/v2.0`,
22
+ discovery: {
23
+ issuer: `${authority}/v2.0`,
24
+ authorization_endpoint: `${authority}/oauth2/v2.0/authorize`,
25
+ token_endpoint: `${authority}/oauth2/v2.0/token`,
26
+ jwks_uri: `${authority}/discovery/v2.0/keys`,
27
+ end_session_endpoint: `${authority}/oauth2/v2.0/logout`,
28
+ userinfo_endpoint: "https://graph.microsoft.com/oidc/userinfo",
29
+ },
30
+ jwksPath: "^/discovery/v2\\.0/keys$",
31
+ rewrites: [
32
+ // Real Entra paths carry the tenant; the emulator's do not. The
33
+ // discovery route is the exception — it handles the tenant itself.
34
+ { from: "^/[^/]+/(oauth2/v2\\.0/.*)$", to: "/$1" },
35
+ { from: "^/[^/]+/(discovery/v2\\.0/keys)$", to: "/$1" },
36
+ ],
37
+ fallbackUser: {
38
+ login: users[0].email,
39
+ id: 1,
40
+ scopes: ["openid", "email", "profile", "User.Read"],
41
+ },
42
+ seed: {
43
+ users: users.map((u) => ({ email: u.email, name: u.name })),
44
+ ...oauthClients(opts.client),
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Microsoft, answering at its real endpoints. Drop into
50
+ * `environment.services`:
51
+ *
52
+ * ```ts
53
+ * import { microsoft } from "@specific.dev/spectest/components";
54
+ *
55
+ * services: {
56
+ * microsoft: microsoft({
57
+ * tenantId: "11111111-2222-3333-4444-555555555555",
58
+ * users: [{ email: "bob@example.com", name: "Bob Example" }],
59
+ * client: {
60
+ * clientId: "6f1a2b3c-…",
61
+ * clientSecret: "secret",
62
+ * redirectUris: ["https://app.test/callback/microsoft"],
63
+ * },
64
+ * }),
65
+ * app: { …, dependsOn: ["microsoft"] },
66
+ * }
67
+ * ```
68
+ *
69
+ * The app keeps its production configuration — discovery at
70
+ * `https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration`,
71
+ * and the account read back from `https://graph.microsoft.com/v1.0/me`.
72
+ */
73
+ export function microsoft(opts = {}) {
74
+ const users = resolveUsers("microsoft", opts.users);
75
+ return emulatorService(spec(users, opts));
76
+ }
@@ -0,0 +1,51 @@
1
+ import type { ProviderOptions } from "./emulate/service.js";
2
+ export interface OktaOptions extends ProviderOptions {
3
+ /** The org's domain, e.g. `"dev-12345.okta.com"`. */
4
+ domain: string;
5
+ }
6
+ /**
7
+ * Okta, answering at your org's real domain. Drop into
8
+ * `environment.services`:
9
+ *
10
+ * ```ts
11
+ * import { okta } from "@specific.dev/spectest/components";
12
+ *
13
+ * services: {
14
+ * okta: okta({
15
+ * domain: "dev-12345.okta.com",
16
+ * users: [{ email: "alice@example.com", name: "Alice Example" }],
17
+ * client: {
18
+ * clientId: "0oaexample",
19
+ * clientSecret: "secret",
20
+ * redirectUris: ["https://app.test/callback/okta"],
21
+ * },
22
+ * }),
23
+ * app: { …, dependsOn: ["okta"] },
24
+ * }
25
+ * ```
26
+ *
27
+ * The app keeps its production configuration — discovery at
28
+ * `https://dev-12345.okta.com/.well-known/openid-configuration`, and
29
+ * everything it names.
30
+ */
31
+ export declare function okta(opts: OktaOptions): {
32
+ image: {
33
+ type: "dockerfile";
34
+ content: string;
35
+ };
36
+ command: string;
37
+ files: {
38
+ path: string;
39
+ content: string;
40
+ }[];
41
+ env: {
42
+ SPECTEST_AUTH_CONFIG: string;
43
+ };
44
+ ports: number[];
45
+ readyCheck: {
46
+ type: "http";
47
+ port: number;
48
+ path: string;
49
+ timeoutSecs: number;
50
+ };
51
+ };
@@ -0,0 +1,78 @@
1
+ // `okta()` — Okta, emulated inside the environment, answering at your org's
2
+ // real domain.
3
+ //
4
+ // Today that is Okta sign-in: discovery, the account picker, the token
5
+ // exchange, userinfo, introspection and revocation, on both the org
6
+ // authorization server (`/oauth2/v1/…`) and a custom one
7
+ // (`/oauth2/default/…`). The component is named for the provider rather
8
+ // than for the feature — the backing emulator also carries the users,
9
+ // groups and apps management API.
10
+ //
11
+ // Okta has no fixed hostname: every organization gets its own domain, so
12
+ // `domain` is required where the other providers need nothing.
13
+ import { emulatorService, familyName, givenName, oauthClients, resolveUsers, } from "./emulate/service.js";
14
+ /** Okta's picker identifies an account by its opaque Okta id, so the id has
15
+ * to be seeded rather than generated — otherwise nothing on the page can be
16
+ * mapped back to an address. */
17
+ function oktaId(email) {
18
+ return `u-${email.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
19
+ }
20
+ function spec(users, opts) {
21
+ const domain = opts.domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
22
+ return {
23
+ name: "okta",
24
+ module: "@emulators/okta",
25
+ pluginExport: "oktaPlugin",
26
+ baseUrl: `https://${domain}`,
27
+ hosts: [domain],
28
+ // Okta's issuer is per authorization server and the emulator already
29
+ // builds it from the base URL, which here is the real org domain.
30
+ jwksPath: "^/oauth2/(?:[^/]+/)?v1/keys$",
31
+ aliases: Object.fromEntries(users.map((u) => [oktaId(u.email), u.email])),
32
+ fallbackUser: { login: users[0].email, id: 1, scopes: ["openid", "profile", "email"] },
33
+ seed: {
34
+ users: users.map((u) => ({
35
+ okta_id: oktaId(u.email),
36
+ // An Okta login is the address, not the GitHub-style handle that
37
+ // `login` carries — it comes back as `preferred_username`.
38
+ login: u.email,
39
+ email: u.email,
40
+ first_name: givenName(u),
41
+ last_name: familyName(u),
42
+ display_name: u.name,
43
+ })),
44
+ ...oauthClients(opts.client),
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Okta, answering at your org's real domain. Drop into
50
+ * `environment.services`:
51
+ *
52
+ * ```ts
53
+ * import { okta } from "@specific.dev/spectest/components";
54
+ *
55
+ * services: {
56
+ * okta: okta({
57
+ * domain: "dev-12345.okta.com",
58
+ * users: [{ email: "alice@example.com", name: "Alice Example" }],
59
+ * client: {
60
+ * clientId: "0oaexample",
61
+ * clientSecret: "secret",
62
+ * redirectUris: ["https://app.test/callback/okta"],
63
+ * },
64
+ * }),
65
+ * app: { …, dependsOn: ["okta"] },
66
+ * }
67
+ * ```
68
+ *
69
+ * The app keeps its production configuration — discovery at
70
+ * `https://dev-12345.okta.com/.well-known/openid-configuration`, and
71
+ * everything it names.
72
+ */
73
+ export function okta(opts) {
74
+ const users = resolveUsers("okta", opts.users);
75
+ if (!opts.domain)
76
+ throw new Error("okta(): `domain` is required (e.g. \"dev-12345.okta.com\")");
77
+ return emulatorService(spec(users, opts));
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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}`);