oidc-spa 10.2.12 → 10.2.14

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.
@@ -0,0 +1,490 @@
1
+ import { promises as fs } from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { PluginContext } from "rollup";
4
+ import type { ResolvedConfig, ViteDevServer } from "vite";
5
+ import { babelParser, babelTraverse, type NodePath } from "../vendor/build-runtime/babel";
6
+
7
+ /** The manifest contains names only; values are read by the server at request time. */
8
+ export function createHandleTanstackStartBootstrapEnv(params: { resolvedConfig: ResolvedConfig }) {
9
+ const { resolvedConfig } = params;
10
+ const virtualId = "virtual:oidc-spa/tanstack-start-public-env";
11
+ const resolvedId = `\0${virtualId}`;
12
+ const adapterId = "oidc-spa/react-tanstack-start";
13
+ let scanPromise: Promise<string[]> | undefined;
14
+
15
+ const isSourceFile = (id: string) => /\.[cm]?[jt]sx?$/.test(id) && !/\.d\.[cm]?ts$/.test(id);
16
+
17
+ const scan = async (context: Pick<PluginContext, "resolve" | "addWatchFile">) => {
18
+ type Source = {
19
+ program: NodePath;
20
+ imports: Map<string, string>;
21
+ };
22
+ const sources = new Map<string, Source>();
23
+ const aliases = resolvedConfig.resolve?.alias ?? [];
24
+ const packageImports = await (async (): Promise<Record<string, unknown>> => {
25
+ try {
26
+ const { imports } = JSON.parse(
27
+ await fs.readFile(path.join(resolvedConfig.root, "package.json"), "utf8")
28
+ ) as { imports?: unknown };
29
+ return typeof imports === "object" && imports !== null && !Array.isArray(imports)
30
+ ? (imports as Record<string, unknown>)
31
+ : {};
32
+ } catch {
33
+ return {};
34
+ }
35
+ })();
36
+ const excludedDirectories = new Set([
37
+ "node_modules",
38
+ "dist",
39
+ "build",
40
+ "coverage",
41
+ "public",
42
+ path.resolve(resolvedConfig.root, resolvedConfig.build.outDir),
43
+ path.resolve(resolvedConfig.cacheDir)
44
+ ]);
45
+ const isLocalImport = (specifier: string) => {
46
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
47
+ return true;
48
+ }
49
+ if (specifier.startsWith("#")) {
50
+ return Object.entries(packageImports).some(([key, target]) => {
51
+ const starIndex = key.indexOf("*");
52
+ const matches =
53
+ starIndex === -1
54
+ ? key === specifier
55
+ : specifier.startsWith(key.slice(0, starIndex)) &&
56
+ specifier.endsWith(key.slice(starIndex + 1));
57
+ if (!matches) return false;
58
+ const containsLocalTarget = (target: unknown): boolean => {
59
+ if (typeof target === "string") return target.startsWith(".");
60
+ if (Array.isArray(target)) return target.some(containsLocalTarget);
61
+ return (
62
+ typeof target === "object" &&
63
+ target !== null &&
64
+ Object.values(target).some(containsLocalTarget)
65
+ );
66
+ };
67
+ return containsLocalTarget(target);
68
+ });
69
+ }
70
+ return aliases.some(({ find }) => {
71
+ if (typeof find === "string") {
72
+ return (
73
+ specifier === find ||
74
+ specifier.startsWith(find.endsWith("/") ? find : `${find}/`)
75
+ );
76
+ }
77
+ find.lastIndex = 0;
78
+ return find.test(specifier);
79
+ });
80
+ };
81
+
82
+ // Scan before generating the virtual module, including lazy routes. Collecting
83
+ // names in transform() would make authorization depend on module load order.
84
+ const discover = async (directory: string): Promise<string[]> => {
85
+ context.addWatchFile(directory);
86
+ const entries = await fs.readdir(directory, { withFileTypes: true });
87
+ const files = await Promise.all(
88
+ entries.map(async entry => {
89
+ const id = path.join(directory, entry.name);
90
+ if (
91
+ entry.name.startsWith(".") ||
92
+ excludedDirectories.has(entry.name) ||
93
+ excludedDirectories.has(id)
94
+ ) {
95
+ return [];
96
+ }
97
+ if (entry.isDirectory()) return discover(id);
98
+ return entry.isFile() && isSourceFile(id) ? [id] : [];
99
+ })
100
+ );
101
+ return files.flat();
102
+ };
103
+
104
+ const pending = await discover(resolvedConfig.root);
105
+ for (let i = 0; i < pending.length; i++) {
106
+ const id = pending[i];
107
+ if (sources.has(id)) continue;
108
+ context.addWatchFile(id);
109
+ const code = await fs.readFile(id, "utf8");
110
+ const ast = babelParser.parse(code, {
111
+ sourceType: "unambiguous",
112
+ plugins: [
113
+ "typescript",
114
+ ...(/\.[cm]?ts$/.test(id) ? [] : ["jsx" as const]),
115
+ "decorators-legacy"
116
+ ]
117
+ });
118
+ let program!: NodePath;
119
+ babelTraverse(ast, {
120
+ Program(p) {
121
+ program = p;
122
+ p.stop();
123
+ }
124
+ });
125
+ const source: Source = { program, imports: new Map() };
126
+ sources.set(id, source);
127
+ for (const statement of program.get("body") as NodePath[]) {
128
+ if (
129
+ !statement.isImportDeclaration() &&
130
+ !statement.isExportNamedDeclaration() &&
131
+ !statement.isExportAllDeclaration()
132
+ )
133
+ continue;
134
+ const specifier = statement.node.source?.value;
135
+ // Resolving a bare dependency through Vite's dev client resolver
136
+ // registers it with the dependency optimizer. This scanner only
137
+ // follows local source files, so those resolutions are both harmful
138
+ // and unnecessary.
139
+ if (!specifier || specifier === adapterId || !isLocalImport(specifier)) continue;
140
+ const resolved = await context.resolve(specifier, id, { skipSelf: true });
141
+ if (!resolved || resolved.external) continue;
142
+ const dependencyId = resolved.id.split("?")[0];
143
+ if (
144
+ !path.isAbsolute(dependencyId) ||
145
+ dependencyId.includes(`${path.sep}node_modules${path.sep}`) ||
146
+ !isSourceFile(dependencyId)
147
+ )
148
+ continue;
149
+ source.imports.set(specifier, dependencyId);
150
+ pending.push(dependencyId);
151
+ }
152
+ }
153
+
154
+ const names = new Set<string>();
155
+ const fail: (id: string, p: NodePath, reason: string) => never = (id, p, reason) => {
156
+ const loc = p.node.loc?.start;
157
+ throw new Error(
158
+ `oidc-spa: Cannot determine public bootstrap environment variables in ${id}${
159
+ loc ? `:${loc.line}:${loc.column + 1}` : ""
160
+ }. ${reason}`
161
+ );
162
+ };
163
+ const propertyName = (p: NodePath): string | undefined => {
164
+ if (!p.isMemberExpression() && !p.isOptionalMemberExpression() && !p.isObjectProperty())
165
+ return undefined;
166
+ const key = (p.isObjectProperty() ? p.get("key") : p.get("property")) as NodePath;
167
+ if (key.isStringLiteral()) return key.node.value;
168
+ if (!p.node.computed && key.isIdentifier()) return key.node.name;
169
+ return undefined;
170
+ };
171
+ const unwrap = (p: NodePath): NodePath => {
172
+ while (
173
+ p.isTSAsExpression() ||
174
+ p.isTSSatisfiesExpression() ||
175
+ p.isTSNonNullExpression() ||
176
+ p.isParenthesizedExpression()
177
+ )
178
+ p = p.get("expression") as NodePath;
179
+ return p;
180
+ };
181
+ type Origin = "namespace" | "builder" | "utils" | "bootstrap";
182
+ const memberOrigin = (
183
+ origin: Origin | undefined,
184
+ name: string | undefined
185
+ ): Origin | undefined => {
186
+ if (origin === "namespace" && name === "oidcSpa") return "builder";
187
+ if (origin === "utils" && name === "bootstrapOidc") return "bootstrap";
188
+ return undefined;
189
+ };
190
+ const exportedOrigin = (id: string, name: string, seen: Set<object>): Origin | undefined => {
191
+ if (id === adapterId) return name === "oidcSpa" ? "builder" : undefined;
192
+ const source = sources.get(id);
193
+ if (!source) return undefined;
194
+ for (const statement of source.program.get("body") as NodePath[]) {
195
+ if (seen.has(statement.node)) continue;
196
+ if (statement.isExportDefaultDeclaration() && name === "default")
197
+ return origin(statement.get("declaration"), id, seen);
198
+ if (statement.isExportAllDeclaration()) {
199
+ const result = exportedOrigin(
200
+ source.imports.get(statement.node.source.value) ?? "",
201
+ name,
202
+ new Set([...seen, statement.node])
203
+ );
204
+ if (result) return result;
205
+ }
206
+ if (!statement.isExportNamedDeclaration()) continue;
207
+ const declaration = statement.get("declaration");
208
+ if (
209
+ declaration.node &&
210
+ source.program.scope.getBinding(name)?.path.findParent(p => p === statement)
211
+ ) {
212
+ return bindingOrigin(source.program.scope.getBinding(name)!.path, name, id, seen);
213
+ }
214
+ for (const specifier of statement.get("specifiers")) {
215
+ if (!specifier.isExportSpecifier()) continue;
216
+ const exported = specifier.node.exported;
217
+ if ((exported.type === "Identifier" ? exported.name : exported.value) !== name)
218
+ continue;
219
+ if (!statement.node.source) return origin(specifier.get("local"), id, seen);
220
+ return exportedOrigin(
221
+ statement.node.source.value === adapterId
222
+ ? adapterId
223
+ : source.imports.get(statement.node.source.value) ?? "",
224
+ specifier.node.local.name,
225
+ new Set([...seen, statement.node])
226
+ );
227
+ }
228
+ }
229
+ return undefined;
230
+ };
231
+ const bindingOrigin = (
232
+ p: NodePath,
233
+ name: string,
234
+ id: string,
235
+ seen: Set<object>
236
+ ): Origin | undefined => {
237
+ if (seen.has(p.node)) return undefined;
238
+ seen = new Set([...seen, p.node]);
239
+ if (
240
+ p.isImportSpecifier() ||
241
+ p.isImportDefaultSpecifier() ||
242
+ p.isImportNamespaceSpecifier()
243
+ ) {
244
+ const declaration = p.parentPath;
245
+ if (!declaration?.isImportDeclaration()) return undefined;
246
+ const specifier = declaration.node.source.value;
247
+ if (p.isImportNamespaceSpecifier())
248
+ return specifier === adapterId ? "namespace" : undefined;
249
+ const imported = p.isImportSpecifier() ? p.node.imported : undefined;
250
+ return exportedOrigin(
251
+ specifier === adapterId ? adapterId : sources.get(id)?.imports.get(specifier) ?? "",
252
+ imported
253
+ ? imported.type === "Identifier"
254
+ ? imported.name
255
+ : imported.value
256
+ : "default",
257
+ seen
258
+ );
259
+ }
260
+ if (!p.isVariableDeclarator()) return undefined;
261
+ const valueOrigin = origin(p.get("init") as NodePath, id, seen);
262
+ const pattern = p.get("id");
263
+ if (pattern.isIdentifier()) return valueOrigin;
264
+ if (!pattern.isObjectPattern()) return undefined;
265
+ for (const property of pattern.get("properties")) {
266
+ if (property.isObjectProperty() && property.get("value").isIdentifier({ name }))
267
+ return memberOrigin(valueOrigin, propertyName(property));
268
+ }
269
+ return undefined;
270
+ };
271
+ const origin = (input: NodePath, id: string, seen = new Set<object>()): Origin | undefined => {
272
+ const p = unwrap(input);
273
+ if (!p.node) return undefined;
274
+ if (p.isIdentifier()) {
275
+ const binding = p.scope.getBinding(p.node.name);
276
+ return binding?.constant
277
+ ? bindingOrigin(binding.path, p.node.name, id, seen)
278
+ : undefined;
279
+ }
280
+ if (p.isMemberExpression() || p.isOptionalMemberExpression())
281
+ return memberOrigin(origin(p.get("object") as NodePath, id, seen), propertyName(p));
282
+ if (!p.isCallExpression()) return undefined;
283
+ const callee = p.get("callee");
284
+ if (!callee.isMemberExpression() || origin(callee.get("object"), id, seen) !== "builder")
285
+ return undefined;
286
+ const method = propertyName(callee);
287
+ if (method === "createUtils") return "utils";
288
+ if (
289
+ [
290
+ "withAutoLogin",
291
+ "withExpectedDecodedIdTokenShape",
292
+ "withAccessTokenValidation"
293
+ ].includes(method ?? "")
294
+ )
295
+ return "builder";
296
+ return undefined;
297
+ };
298
+
299
+ for (const [id, source] of sources) {
300
+ source.program.traverse({
301
+ CallExpression(call) {
302
+ if (origin(call.get("callee"), id) !== "bootstrap") return;
303
+ let callback: NodePath = call.get("arguments")[0];
304
+ if (!callback) fail(id, call, "bootstrapOidc requires an argument.");
305
+ callback = unwrap(callback);
306
+ const seenCallbacks = new Set<object>();
307
+ while (callback.isIdentifier()) {
308
+ const binding = callback.scope.getBinding(callback.node.name);
309
+ if (!binding?.constant || seenCallbacks.has(binding.path.node)) break;
310
+ seenCallbacks.add(binding.path.node);
311
+ if (binding.path.isFunctionDeclaration()) {
312
+ callback = binding.path;
313
+ break;
314
+ }
315
+ if (!binding.path.isVariableDeclarator()) break;
316
+ callback = unwrap(binding.path.get("init") as NodePath);
317
+ }
318
+ // Direct configuration objects do not receive the env proxy.
319
+ if (callback.isObjectExpression()) return;
320
+ if (!callback.isFunction())
321
+ fail(
322
+ id,
323
+ callback,
324
+ "Use an inline callback or a locally declared callback so its environment accesses can be analyzed."
325
+ );
326
+ if (callback.node.async || callback.node.generator)
327
+ fail(id, callback, "The bootstrap callback must be synchronous.");
328
+
329
+ type ValueKind = "argument" | "process" | "env";
330
+ const visited = new Set<object>();
331
+ const followPattern = (pattern: NodePath, kind: ValueKind): void => {
332
+ if (pattern.isIdentifier()) {
333
+ const binding = pattern.scope.getBinding(pattern.node.name);
334
+ if (!binding || !binding.constant)
335
+ fail(id, pattern, "Do not reassign the bootstrap environment bindings.");
336
+ if (visited.has(binding)) return;
337
+ visited.add(binding);
338
+ binding.referencePaths.forEach(reference =>
339
+ followReference(reference, kind)
340
+ );
341
+ return;
342
+ }
343
+ if (!pattern.isObjectPattern())
344
+ fail(
345
+ id,
346
+ pattern,
347
+ "Use named properties when destructuring the bootstrap environment; rest and default bindings are unsupported."
348
+ );
349
+ for (const property of pattern.get("properties")) {
350
+ if (!property.isObjectProperty())
351
+ fail(id, property, "Do not spread the bootstrap environment.");
352
+ const name = propertyName(property);
353
+ if (name === undefined)
354
+ fail(
355
+ id,
356
+ property,
357
+ "Environment variable names must be literal properties."
358
+ );
359
+ if (kind === "env") {
360
+ names.add(name);
361
+ continue;
362
+ }
363
+ if (name !== (kind === "argument" ? "process" : "env"))
364
+ fail(
365
+ id,
366
+ property,
367
+ "Only process.env is available in the bootstrap environment."
368
+ );
369
+ followPattern(
370
+ property.get("value"),
371
+ kind === "argument" ? "process" : "env"
372
+ );
373
+ }
374
+ };
375
+ const followReference = (reference: NodePath, kind: ValueKind): void => {
376
+ let parent = reference.parentPath;
377
+ while (
378
+ parent &&
379
+ (parent.isTSAsExpression() ||
380
+ parent.isTSSatisfiesExpression() ||
381
+ parent.isTSNonNullExpression() ||
382
+ parent.isParenthesizedExpression())
383
+ ) {
384
+ reference = parent;
385
+ parent = reference.parentPath;
386
+ }
387
+ if (!parent) return;
388
+ if (parent.isMemberExpression() || parent.isOptionalMemberExpression()) {
389
+ if (parent.get("object") !== reference)
390
+ fail(
391
+ id,
392
+ reference,
393
+ "The bootstrap environment cannot be used as a property key."
394
+ );
395
+ const name = propertyName(parent);
396
+ if (name === undefined)
397
+ fail(
398
+ id,
399
+ parent,
400
+ 'Use process.env.NAME or process.env["NAME"]; computed environment variable names cannot be authorized safely.'
401
+ );
402
+ if (kind === "env") {
403
+ const use = parent.parentPath;
404
+ if (
405
+ (use?.isAssignmentExpression() && use.get("left") === parent) ||
406
+ use?.isUpdateExpression() ||
407
+ use?.isUnaryExpression({ operator: "delete" })
408
+ )
409
+ fail(id, parent, "Do not mutate the bootstrap environment.");
410
+ names.add(name);
411
+ return;
412
+ }
413
+ if (name !== (kind === "argument" ? "process" : "env"))
414
+ fail(
415
+ id,
416
+ parent,
417
+ "Only process.env is available in the bootstrap environment."
418
+ );
419
+ followReference(parent, kind === "argument" ? "process" : "env");
420
+ return;
421
+ }
422
+ if (parent.isVariableDeclarator() && parent.get("init") === reference) {
423
+ followPattern(parent.get("id"), kind);
424
+ return;
425
+ }
426
+ if (
427
+ kind === "env" &&
428
+ parent.isBinaryExpression({ operator: "in" }) &&
429
+ parent.get("right") === reference
430
+ ) {
431
+ const name = parent.get("left");
432
+ if (!name.isStringLiteral())
433
+ fail(
434
+ id,
435
+ name,
436
+ "Use a string literal for environment membership checks."
437
+ );
438
+ names.add(name.node.value);
439
+ return;
440
+ }
441
+ if (parent.isTSTypeQuery()) return;
442
+ fail(
443
+ id,
444
+ reference,
445
+ "Do not pass, spread, or return the bootstrap environment object. Read its literal named variables inside the callback instead."
446
+ );
447
+ };
448
+ const parameter = callback.get("params")[0];
449
+ if (parameter) followPattern(parameter, "argument");
450
+ }
451
+ });
452
+ }
453
+ return [...names].sort();
454
+ };
455
+
456
+ return {
457
+ resolveId: (id: string) => (id === virtualId ? resolvedId : null),
458
+ load: async (id: string, context: Pick<PluginContext, "resolve" | "addWatchFile">) => {
459
+ if (id !== resolvedId) return null;
460
+ // The manifest is only imported by the server function. Delaying the
461
+ // complete scan avoids Vite's dev dependency-optimizer startup phase.
462
+ const names = await (scanPromise ??= scan(context));
463
+ return `export const publicEnvNames = new Set(${JSON.stringify(names)});\n`;
464
+ },
465
+ configureServer: (server: ViteDevServer) => {
466
+ const onSourceChange = (id: string) => {
467
+ if (!isSourceFile(id) || id.includes(`${path.sep}node_modules${path.sep}`)) return;
468
+ scanPromise = undefined;
469
+ // The virtual module has no real filename for Vite to invalidate.
470
+ // Revoke removed names as well as discovering newly added names.
471
+ for (const environment of Object.values(server.environments)) {
472
+ const module = environment.moduleGraph.getModuleById(resolvedId);
473
+ if (!module) continue;
474
+ environment.moduleGraph.invalidateModule(module);
475
+ environment.hot.send({ type: "full-reload" });
476
+ }
477
+ };
478
+ server.watcher
479
+ .on("add", onSourceChange)
480
+ .on("change", onSourceChange)
481
+ .on("unlink", onSourceChange);
482
+ server.httpServer?.once("close", () => {
483
+ server.watcher
484
+ .off("add", onSourceChange)
485
+ .off("change", onSourceChange)
486
+ .off("unlink", onSourceChange);
487
+ });
488
+ }
489
+ };
490
+ }
@@ -7,6 +7,7 @@ import { manageOptimizedDeps } from "./manageOptimizedDeps";
7
7
  import { transformCreateFileRoute } from "./transformTanstackRouterCreateFileRoute";
8
8
  import { getProjectType, type ProjectType } from "./projectType";
9
9
  import { createHandleTanstackStartClientOutput } from "./handleTanstackStartClientOutput";
10
+ import { createHandleTanstackStartBootstrapEnv } from "./handleTanstackStartBootstrapEnv";
10
11
 
11
12
  export type OidcSpaVitePluginParams = {
12
13
  /** See: https://docs.oidc-spa.dev/v/v10/security-features/browser-runtime-freeze */
@@ -87,6 +88,7 @@ export function oidcSpa(params: OidcSpaVitePluginParams = {}) {
87
88
 
88
89
  let projectType: ProjectType | undefined = undefined;
89
90
  let isBuild = false;
91
+ let bootstrapEnvHandler: ReturnType<typeof createHandleTanstackStartBootstrapEnv> | undefined;
90
92
 
91
93
  const plugin: Plugin = {
92
94
  name: "oidc-spa",
@@ -110,6 +112,10 @@ export function oidcSpa(params: OidcSpaVitePluginParams = {}) {
110
112
  pluginNames: resolvedConfig.plugins.map(({ name }) => name)
111
113
  });
112
114
 
115
+ if (projectType === "tanstack-start") {
116
+ bootstrapEnvHandler = createHandleTanstackStartBootstrapEnv({ resolvedConfig });
117
+ }
118
+
113
119
  const clientEntrypointHandler = createHandleClientEntrypoint({
114
120
  oidcSpaVitePluginParams: params,
115
121
  resolvedConfig,
@@ -157,12 +163,23 @@ export function oidcSpa(params: OidcSpaVitePluginParams = {}) {
157
163
  return transformed;
158
164
  },
159
165
  resolveId(id) {
160
- return resolveId_handleTanstackStartClientOutput?.(id) ?? null;
166
+ return (
167
+ bootstrapEnvHandler?.resolveId(id) ??
168
+ resolveId_handleTanstackStartClientOutput?.(id) ??
169
+ null
170
+ );
161
171
  },
162
172
  buildStart() {
163
173
  buildStart_handleTanstackStartClientOutput?.call(this);
164
174
  },
175
+ configureServer(server) {
176
+ bootstrapEnvHandler?.configureServer(server);
177
+ },
165
178
  async load(id) {
179
+ {
180
+ const r = await bootstrapEnvHandler?.load(id, this);
181
+ if (r !== null && r !== undefined) return r;
182
+ }
166
183
  {
167
184
  const r = load_handleTanstackStartClientOutput?.(id) ?? null;
168
185