swallowkit 1.0.0-beta.45 → 1.0.0-beta.47
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/cli/commands/add-auth.d.ts +2 -0
- package/dist/cli/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +214 -16
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/dev.d.ts +4 -0
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +100 -12
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +74 -30
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +37 -5
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +3 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts +20 -2
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +265 -12
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/connector-functions-generator.d.ts +3 -3
- package/dist/core/scaffold/connector-functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/connector-functions-generator.js +19 -19
- package/dist/core/scaffold/connector-functions-generator.js.map +1 -1
- package/dist/core/scaffold/functions-generator.d.ts +2 -2
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +16 -16
- package/dist/core/scaffold/functions-generator.js.map +1 -1
- package/dist/core/scaffold/nextjs-generator.d.ts.map +1 -1
- package/dist/core/scaffold/nextjs-generator.js +13 -1
- package/dist/core/scaffold/nextjs-generator.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +20 -20
- package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +13 -1
- package/src/__tests__/auth.test.ts +103 -0
- package/src/__tests__/dev.test.ts +27 -0
- package/src/__tests__/init.test.ts +27 -0
- package/src/__tests__/nextjs-generator.test.ts +8 -0
- package/src/cli/commands/add-auth.ts +239 -16
- package/src/cli/commands/dev.ts +111 -14
- package/src/cli/commands/init.ts +76 -30
- package/src/cli/commands/scaffold.ts +37 -5
- package/src/cli/index.ts +3 -1
- package/src/core/scaffold/auth-generator.ts +267 -13
- package/src/core/scaffold/connector-functions-generator.ts +22 -19
- package/src/core/scaffold/functions-generator.ts +17 -17
- package/src/core/scaffold/nextjs-generator.ts +13 -1
- package/src/types/index.ts +1 -1
|
@@ -13,6 +13,17 @@ import {
|
|
|
13
13
|
generateLoginPage,
|
|
14
14
|
generateAuthContext,
|
|
15
15
|
generateBFFCallFunctionWithAuth,
|
|
16
|
+
generateBFFCallFunctionWithSwaAuth,
|
|
17
|
+
generateSwaAuthHelperTS,
|
|
18
|
+
generateSwaAuthHelperCSharp,
|
|
19
|
+
generateSwaAuthHelperPython,
|
|
20
|
+
generateExternalTokenAdapter,
|
|
21
|
+
generateAuthenticatedFetch,
|
|
22
|
+
generateExternalTokenAuthContext,
|
|
23
|
+
generateExternalTokenVerifierTS,
|
|
24
|
+
generateExternalTokenHelperTS,
|
|
25
|
+
generateExternalTokenVerifierCSharp,
|
|
26
|
+
generateExternalTokenVerifierPython,
|
|
16
27
|
generateAuthImportTS,
|
|
17
28
|
generateAuthGuardTS,
|
|
18
29
|
generateAuthGuardCSharp,
|
|
@@ -22,6 +33,7 @@ import { generateCompactAzureFunctionsCRUD } from "../core/scaffold/functions-ge
|
|
|
22
33
|
import { parseAuthPolicy } from "../core/scaffold/model-parser";
|
|
23
34
|
import { createBasicModelInfo } from "./fixtures";
|
|
24
35
|
import { ModelAuthPolicy, CustomJwtConfig } from "../types";
|
|
36
|
+
import { addAuthProviderToRootLayout, generateSwaAuthContext } from "../cli/commands/add-auth";
|
|
25
37
|
|
|
26
38
|
const defaultJwtConfig: CustomJwtConfig = {
|
|
27
39
|
userConnector: "mysql",
|
|
@@ -325,6 +337,97 @@ describe("generateBFFCallFunctionWithAuth", () => {
|
|
|
325
337
|
const code = generateBFFCallFunctionWithAuth();
|
|
326
338
|
expect(code).toContain("catch");
|
|
327
339
|
});
|
|
340
|
+
|
|
341
|
+
it("also attaches the Functions host key", () => {
|
|
342
|
+
const code = generateBFFCallFunctionWithAuth();
|
|
343
|
+
expect(code).toContain("BACKEND_FUNCTIONS_KEY");
|
|
344
|
+
expect(code).toContain("'x-functions-key'");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("SWA built-in auth generators", () => {
|
|
349
|
+
it("wraps the root layout with AuthProvider exactly once", () => {
|
|
350
|
+
const layout = `import './globals.css';\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return <html><body>{children}</body></html>;\n}\n`;
|
|
351
|
+
const once = addAuthProviderToRootLayout(layout);
|
|
352
|
+
const twice = addAuthProviderToRootLayout(once);
|
|
353
|
+
|
|
354
|
+
expect(twice).toBe(once);
|
|
355
|
+
expect(once.match(/import \{ AuthProvider \}/g)).toHaveLength(1);
|
|
356
|
+
expect(once.match(/<AuthProvider>/g)).toHaveLength(1);
|
|
357
|
+
expect(once).toContain("<AuthProvider>{children}</AuthProvider>");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("preserves a React directive before the generated import", () => {
|
|
361
|
+
const layout = `'use client';\nimport { useAuth } from '@/lib/auth/auth-context';\nexport default function RootLayout({ children }: { children: React.ReactNode }) { return <body>{children}</body>; }`;
|
|
362
|
+
const updated = addAuthProviderToRootLayout(layout);
|
|
363
|
+
|
|
364
|
+
expect(updated).toMatch(/^'use client';\nimport \{ AuthProvider \}/);
|
|
365
|
+
expect(updated.match(/AuthProvider \} from/g)).toHaveLength(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("treats unavailable or invalid /.auth/me responses as anonymous", () => {
|
|
369
|
+
const code = generateSwaAuthContext();
|
|
370
|
+
expect(code).toContain("if (!response.ok)");
|
|
371
|
+
expect(code).toContain("typeof body !== 'object'");
|
|
372
|
+
expect(code).toContain(".catch(() => setUser(null))");
|
|
373
|
+
expect(code).toContain(".finally(() => setLoading(false))");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("normalizes a valid SWA principal and its string roles", () => {
|
|
377
|
+
const code = generateSwaAuthContext();
|
|
378
|
+
expect(code).toContain("typeof value.userId !== 'string'");
|
|
379
|
+
expect(code).toContain("value.userRoles.filter");
|
|
380
|
+
expect(code).toContain("satisfies AuthUser");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("forwards a normalized platform principal with /.auth/me fallback", () => {
|
|
384
|
+
const code = generateBFFCallFunctionWithSwaAuth();
|
|
385
|
+
expect(code).toContain("x-ms-client-principal");
|
|
386
|
+
expect(code).toContain("/.auth/me");
|
|
387
|
+
expect(code).toContain("BACKEND_FUNCTIONS_KEY");
|
|
388
|
+
expect(code).not.toContain("fetchHeaders['Authorization']");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it.each([
|
|
392
|
+
["typescript", generateSwaAuthHelperTS()],
|
|
393
|
+
["csharp", generateSwaAuthHelperCSharp()],
|
|
394
|
+
["python", generateSwaAuthHelperPython()],
|
|
395
|
+
])("generates %s principal validation and role checks", (_language, code) => {
|
|
396
|
+
expect(code).toContain("x-ms-client-principal");
|
|
397
|
+
expect(code).toContain("authenticated");
|
|
398
|
+
expect(code.toLowerCase()).toContain("role");
|
|
399
|
+
expect(code).not.toContain("JWT_SECRET");
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("external token auth generators", () => {
|
|
404
|
+
it("generates a fail-closed frontend adapter and authenticated fetch", () => {
|
|
405
|
+
expect(generateExternalTokenAdapter()).toContain("return null");
|
|
406
|
+
const fetchCode = generateAuthenticatedFetch();
|
|
407
|
+
expect(fetchCode).toContain("Authorization");
|
|
408
|
+
expect(fetchCode).toContain("Bearer");
|
|
409
|
+
expect(fetchCode).toContain("status: 401");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("handles non-JSON auth/me responses without parsing them", () => {
|
|
413
|
+
const code = generateExternalTokenAuthContext();
|
|
414
|
+
expect(code).toContain("content-type");
|
|
415
|
+
expect(code).toContain(".catch(() => setUser(null))");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it.each([
|
|
419
|
+
["typescript", generateExternalTokenVerifierTS()],
|
|
420
|
+
["csharp", generateExternalTokenVerifierCSharp()],
|
|
421
|
+
["python", generateExternalTokenVerifierPython()],
|
|
422
|
+
])("generates a fail-closed %s verifier", (_language, code) => {
|
|
423
|
+
expect(code).toContain("External token verifier is not configured");
|
|
424
|
+
expect(code).not.toContain("JWT_SECRET");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("awaits external token verification in TypeScript guards", () => {
|
|
428
|
+
expect(generateAuthGuardTS({}, "read", "external-token")).toContain("await requireAuth(request)");
|
|
429
|
+
expect(generateExternalTokenHelperTS()).toContain("await verifyExternalToken");
|
|
430
|
+
});
|
|
328
431
|
});
|
|
329
432
|
|
|
330
433
|
// ============================================================
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
buildFunctionsStartArgs,
|
|
7
7
|
buildFunctionsCoreToolsCommand,
|
|
8
8
|
buildNextDevArgs,
|
|
9
|
+
buildSwaStartArgs,
|
|
9
10
|
buildDevCommand,
|
|
10
11
|
buildPythonFunctionsEnv,
|
|
11
12
|
compareVersionNumbers,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
getFunctionsReadinessTimeoutMs,
|
|
14
15
|
getCSharpFunctionsBuildArtifactPaths,
|
|
15
16
|
getPythonVirtualEnvPaths,
|
|
17
|
+
getSwaCliInstallCommand,
|
|
16
18
|
parseCoreToolsVersion,
|
|
17
19
|
waitForHttpServerReady,
|
|
18
20
|
} from "../cli/commands/dev";
|
|
@@ -70,6 +72,23 @@ describe("dev command helpers", () => {
|
|
|
70
72
|
expect(buildFunctionsBaseUrl("127.0.0.1", "7072")).toBe("http://127.0.0.1:7072");
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
it("builds SWA CLI arguments without bypassing the Next.js BFF", () => {
|
|
76
|
+
expect(buildSwaStartArgs(undefined, "3000", "4280")).toEqual([
|
|
77
|
+
"start",
|
|
78
|
+
"http://localhost:3000",
|
|
79
|
+
"--swa-config-location",
|
|
80
|
+
".",
|
|
81
|
+
"--port",
|
|
82
|
+
"4280",
|
|
83
|
+
]);
|
|
84
|
+
expect(buildSwaStartArgs(undefined, "3000", "4280")).not.toContain("--api-devserver-url");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("provides a project-local SWA CLI install command", () => {
|
|
88
|
+
expect(getSwaCliInstallCommand("pnpm")).toBe("pnpm add -Dw @azure/static-web-apps-cli");
|
|
89
|
+
expect(getSwaCliInstallCommand("npm")).toBe("npm install -D @azure/static-web-apps-cli");
|
|
90
|
+
});
|
|
91
|
+
|
|
73
92
|
it("gives C# more startup time before reporting readiness timeout", () => {
|
|
74
93
|
expect(getFunctionsReadinessTimeoutMs("csharp")).toBeGreaterThan(getFunctionsReadinessTimeoutMs("typescript"));
|
|
75
94
|
expect(getFunctionsReadinessTimeoutMs("csharp")).toBeGreaterThan(getFunctionsReadinessTimeoutMs("python"));
|
|
@@ -197,6 +216,8 @@ describe("dev CLI parser", () => {
|
|
|
197
216
|
noFunctions: true,
|
|
198
217
|
seedEnv: "local",
|
|
199
218
|
mockConnectors: true,
|
|
219
|
+
swaPort: "4290",
|
|
220
|
+
noSwa: true,
|
|
200
221
|
};
|
|
201
222
|
|
|
202
223
|
const optionsAfterCommand = await parseDevOptions([
|
|
@@ -215,6 +236,9 @@ describe("dev CLI parser", () => {
|
|
|
215
236
|
"--seed-env",
|
|
216
237
|
"local",
|
|
217
238
|
"--mock-connectors",
|
|
239
|
+
"--swa-port",
|
|
240
|
+
"4290",
|
|
241
|
+
"--no-swa",
|
|
218
242
|
]);
|
|
219
243
|
|
|
220
244
|
const optionsBeforeCommand = await parseDevOptions([
|
|
@@ -226,6 +250,9 @@ describe("dev CLI parser", () => {
|
|
|
226
250
|
"--seed-env",
|
|
227
251
|
"local",
|
|
228
252
|
"--mock-connectors",
|
|
253
|
+
"--swa-port",
|
|
254
|
+
"4290",
|
|
255
|
+
"--no-swa",
|
|
229
256
|
"--no-functions",
|
|
230
257
|
"--port",
|
|
231
258
|
"3001",
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
injectSwallowKitNextConfig,
|
|
18
18
|
parseIgnoredBuilds,
|
|
19
19
|
buildCosmosDbFreeTierBicepSource,
|
|
20
|
+
buildFunctionsHostKeyBicepExpression,
|
|
21
|
+
buildStaticWebAppConfigBicepSource,
|
|
20
22
|
withNpmLockfileSafeManifests,
|
|
21
23
|
} from "../cli/commands/init";
|
|
22
24
|
|
|
@@ -100,6 +102,7 @@ module.exports = nextConfig;
|
|
|
100
102
|
expect(dependencies).toEqual({
|
|
101
103
|
"@azure/cosmos": "^4.0.0",
|
|
102
104
|
applicationinsights: "^3.3.0",
|
|
105
|
+
zod: "^4.0.0",
|
|
103
106
|
"@sample-app/shared": "workspace:*",
|
|
104
107
|
});
|
|
105
108
|
expect(dependencies).not.toHaveProperty("swallowkit");
|
|
@@ -141,6 +144,10 @@ module.exports = nextConfig;
|
|
|
141
144
|
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-lockfile-"));
|
|
142
145
|
fs.mkdirSync(path.join(projectDir, "shared"));
|
|
143
146
|
fs.mkdirSync(path.join(projectDir, "functions"));
|
|
147
|
+
fs.mkdirSync(path.join(projectDir, "node_modules"));
|
|
148
|
+
fs.mkdirSync(path.join(projectDir, "shared", "node_modules"));
|
|
149
|
+
fs.writeFileSync(path.join(projectDir, "node_modules", ".pnpm-marker"), "root");
|
|
150
|
+
fs.writeFileSync(path.join(projectDir, "shared", "node_modules", ".pnpm-marker"), "shared");
|
|
144
151
|
|
|
145
152
|
const rootPackageJson = {
|
|
146
153
|
name: "sample-app",
|
|
@@ -168,10 +175,14 @@ module.exports = nextConfig;
|
|
|
168
175
|
|
|
169
176
|
expect(normalizedRoot.dependencies["@sample-app/shared"]).toBe("file:shared");
|
|
170
177
|
expect(normalizedFunctions.dependencies["@sample-app/shared"]).toBe("file:../shared");
|
|
178
|
+
expect(fs.existsSync(path.join(projectDir, "node_modules"))).toBe(false);
|
|
179
|
+
expect(fs.existsSync(path.join(projectDir, "shared", "node_modules"))).toBe(false);
|
|
171
180
|
});
|
|
172
181
|
|
|
173
182
|
expect(JSON.parse(fs.readFileSync(path.join(projectDir, "package.json"), "utf-8"))).toEqual(rootPackageJson);
|
|
174
183
|
expect(JSON.parse(fs.readFileSync(path.join(projectDir, "functions", "package.json"), "utf-8"))).toEqual(functionsPackageJson);
|
|
184
|
+
expect(fs.readFileSync(path.join(projectDir, "node_modules", ".pnpm-marker"), "utf-8")).toBe("root");
|
|
185
|
+
expect(fs.readFileSync(path.join(projectDir, "shared", "node_modules", ".pnpm-marker"), "utf-8")).toBe("shared");
|
|
175
186
|
});
|
|
176
187
|
});
|
|
177
188
|
|
|
@@ -204,6 +215,22 @@ describe("buildCosmosDbFreeTierBicepSource", () => {
|
|
|
204
215
|
});
|
|
205
216
|
});
|
|
206
217
|
|
|
218
|
+
describe("Functions host key infrastructure", () => {
|
|
219
|
+
it("reads the generated default host key without exposing a literal value", () => {
|
|
220
|
+
const expression = buildFunctionsHostKeyBicepExpression();
|
|
221
|
+
expect(expression).toContain("listKeys(");
|
|
222
|
+
expect(expression).toContain("functionKeys.default");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("passes the key to SWA through a secure module parameter", () => {
|
|
226
|
+
const source = buildStaticWebAppConfigBicepSource();
|
|
227
|
+
expect(source).toContain("@secure()");
|
|
228
|
+
expect(source).toContain("param functionsHostKey string");
|
|
229
|
+
expect(source).toContain("BACKEND_FUNCTIONS_KEY: functionsHostKey");
|
|
230
|
+
expect(source).not.toContain("output functionsHostKey");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
207
234
|
describe("GitHub Actions workflow generation", () => {
|
|
208
235
|
it("uses npm ci for the SWA workflow when the project was initialized with pnpm", () => {
|
|
209
236
|
const workflow = buildGitHubSwaWorkflow("npm", "pnpm");
|
|
@@ -21,6 +21,14 @@ describe("generateBFFCallFunction", () => {
|
|
|
21
21
|
expect(code).toContain("export async function callFunction");
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it("uses a server-side Functions key and fails closed outside localhost", () => {
|
|
25
|
+
const code = generateBFFCallFunction();
|
|
26
|
+
expect(code).toContain("BACKEND_FUNCTIONS_KEY");
|
|
27
|
+
expect(code).toContain("'x-functions-key'");
|
|
28
|
+
expect(code).toContain("required for non-local Functions calls");
|
|
29
|
+
expect(code).not.toContain("?code=");
|
|
30
|
+
});
|
|
31
|
+
|
|
24
32
|
it("includes z.ZodSchema typings", () => {
|
|
25
33
|
const code = generateBFFCallFunction();
|
|
26
34
|
expect(code).toContain("z.ZodSchema");
|
|
@@ -22,6 +22,23 @@ import {
|
|
|
22
22
|
generateLoginPage,
|
|
23
23
|
generateAuthContext,
|
|
24
24
|
generateBFFCallFunctionWithAuth,
|
|
25
|
+
generateBFFCallFunctionWithSwaAuth,
|
|
26
|
+
generateSwaAuthHelperTS,
|
|
27
|
+
generateSwaAuthHelperCSharp,
|
|
28
|
+
generateSwaAuthHelperPython,
|
|
29
|
+
generateExternalTokenAdapter,
|
|
30
|
+
generateAuthenticatedFetch,
|
|
31
|
+
generateExternalTokenBFFMeRoute,
|
|
32
|
+
generateExternalTokenAuthContext,
|
|
33
|
+
generateExternalTokenVerifierTS,
|
|
34
|
+
generateExternalTokenHelperTS,
|
|
35
|
+
generateExternalAuthMeTS,
|
|
36
|
+
generateExternalTokenVerifierCSharp,
|
|
37
|
+
generateExternalTokenHelperCSharp,
|
|
38
|
+
generateExternalAuthMeCSharp,
|
|
39
|
+
generateExternalTokenVerifierPython,
|
|
40
|
+
generateExternalTokenHelperPython,
|
|
41
|
+
generateExternalAuthMePython,
|
|
25
42
|
} from "../../core/scaffold/auth-generator";
|
|
26
43
|
import { syncProjectManifest } from "../../core/project/manifest";
|
|
27
44
|
import { buildSharedTsConfig } from "./init";
|
|
@@ -30,14 +47,194 @@ interface AddAuthOptions {
|
|
|
30
47
|
provider?: string;
|
|
31
48
|
}
|
|
32
49
|
|
|
50
|
+
function writeIfMissing(filePath: string, content: string): void {
|
|
51
|
+
if (fs.existsSync(filePath)) return;
|
|
52
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultCustomJwtConfig(): CustomJwtConfig {
|
|
57
|
+
return {
|
|
58
|
+
userConnector: "mysql",
|
|
59
|
+
userTable: "users",
|
|
60
|
+
loginIdColumn: "login_id",
|
|
61
|
+
passwordHashColumn: "password_hash",
|
|
62
|
+
rolesColumn: "roles",
|
|
63
|
+
jwtSecretEnv: "JWT_SECRET",
|
|
64
|
+
tokenExpiry: "24h",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setupSwaAuth(cwd: string, backendLanguage: BackendLanguage): void {
|
|
69
|
+
const helperPath = backendLanguage === "typescript"
|
|
70
|
+
? path.join(cwd, "functions", "src", "auth", "jwt-helper.ts")
|
|
71
|
+
: backendLanguage === "csharp"
|
|
72
|
+
? path.join(cwd, "functions", "Auth", "JwtHelper.cs")
|
|
73
|
+
: path.join(cwd, "functions", "auth", "jwt_helper.py");
|
|
74
|
+
fs.mkdirSync(path.dirname(helperPath), { recursive: true });
|
|
75
|
+
const helper = backendLanguage === "typescript"
|
|
76
|
+
? generateSwaAuthHelperTS()
|
|
77
|
+
: backendLanguage === "csharp"
|
|
78
|
+
? generateSwaAuthHelperCSharp()
|
|
79
|
+
: generateSwaAuthHelperPython();
|
|
80
|
+
fs.writeFileSync(helperPath, helper, "utf-8");
|
|
81
|
+
|
|
82
|
+
const callFnPath = path.join(cwd, "lib", "api", "call-function.ts");
|
|
83
|
+
fs.mkdirSync(path.dirname(callFnPath), { recursive: true });
|
|
84
|
+
fs.writeFileSync(callFnPath, generateBFFCallFunctionWithSwaAuth(), "utf-8");
|
|
85
|
+
|
|
86
|
+
const authDir = path.join(cwd, "lib", "auth");
|
|
87
|
+
fs.mkdirSync(authDir, { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(authDir, "auth-context.tsx"), generateSwaAuthContext(), "utf-8");
|
|
89
|
+
updateRootLayoutWithAuthProvider(cwd);
|
|
90
|
+
const loginDir = path.join(cwd, "app", "login");
|
|
91
|
+
fs.mkdirSync(loginDir, { recursive: true });
|
|
92
|
+
fs.writeFileSync(path.join(loginDir, "page.tsx"), generateSwaLoginPage(), "utf-8");
|
|
93
|
+
updateSwaRouteConfig(cwd);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setupExternalTokenAuth(cwd: string, backendLanguage: BackendLanguage): void {
|
|
97
|
+
const authDir = path.join(cwd, "lib", "auth");
|
|
98
|
+
fs.mkdirSync(authDir, { recursive: true });
|
|
99
|
+
writeIfMissing(path.join(authDir, "external-token-adapter.ts"), generateExternalTokenAdapter());
|
|
100
|
+
fs.writeFileSync(path.join(authDir, "authenticated-fetch.ts"), generateAuthenticatedFetch(), "utf-8");
|
|
101
|
+
fs.writeFileSync(path.join(authDir, "auth-context.tsx"), generateExternalTokenAuthContext(), "utf-8");
|
|
102
|
+
fs.writeFileSync(path.join(cwd, "lib", "api", "call-function.ts"), generateBFFCallFunctionWithAuth(), "utf-8");
|
|
103
|
+
const meDir = path.join(cwd, "app", "api", "auth", "me");
|
|
104
|
+
fs.mkdirSync(meDir, { recursive: true });
|
|
105
|
+
fs.writeFileSync(path.join(meDir, "route.ts"), generateExternalTokenBFFMeRoute(), "utf-8");
|
|
106
|
+
updateRootLayoutWithAuthProvider(cwd);
|
|
107
|
+
|
|
108
|
+
if (backendLanguage === "typescript") {
|
|
109
|
+
const verifier = path.join(cwd, "functions", "src", "auth", "external-token-verifier.ts");
|
|
110
|
+
writeIfMissing(verifier, generateExternalTokenVerifierTS());
|
|
111
|
+
fs.writeFileSync(path.join(cwd, "functions", "src", "auth", "jwt-helper.ts"), generateExternalTokenHelperTS(), "utf-8");
|
|
112
|
+
fs.writeFileSync(path.join(cwd, "functions", "src", "auth-me.ts"), generateExternalAuthMeTS(), "utf-8");
|
|
113
|
+
} else if (backendLanguage === "csharp") {
|
|
114
|
+
const verifier = path.join(cwd, "functions", "Auth", "ExternalTokenVerifier.cs");
|
|
115
|
+
writeIfMissing(verifier, generateExternalTokenVerifierCSharp());
|
|
116
|
+
fs.writeFileSync(path.join(cwd, "functions", "Auth", "JwtHelper.cs"), generateExternalTokenHelperCSharp(), "utf-8");
|
|
117
|
+
fs.writeFileSync(path.join(cwd, "functions", "Auth", "ExternalAuthMe.cs"), generateExternalAuthMeCSharp(), "utf-8");
|
|
118
|
+
} else {
|
|
119
|
+
const verifier = path.join(cwd, "functions", "auth", "external_token_verifier.py");
|
|
120
|
+
writeIfMissing(verifier, generateExternalTokenVerifierPython());
|
|
121
|
+
fs.writeFileSync(path.join(cwd, "functions", "auth", "jwt_helper.py"), generateExternalTokenHelperPython(), "utf-8");
|
|
122
|
+
const authMePath = path.join(cwd, "functions", "blueprints", "auth_me.py");
|
|
123
|
+
fs.mkdirSync(path.dirname(authMePath), { recursive: true });
|
|
124
|
+
fs.writeFileSync(authMePath, generateExternalAuthMePython(), "utf-8");
|
|
125
|
+
updatePythonAuthMeRegistration(cwd);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function updatePythonAuthMeRegistration(cwd: string): void {
|
|
130
|
+
const appPath = path.join(cwd, "functions", "function_app.py");
|
|
131
|
+
if (!fs.existsSync(appPath)) return;
|
|
132
|
+
const importLine = "from blueprints.auth_me import bp as external_auth_me_bp";
|
|
133
|
+
const registerLine = "app.register_blueprint(external_auth_me_bp)";
|
|
134
|
+
let content = fs.readFileSync(appPath, "utf-8");
|
|
135
|
+
if (content.includes(importLine)) return;
|
|
136
|
+
const marker = "# SwallowKit scaffold registrations";
|
|
137
|
+
content = content.includes(marker)
|
|
138
|
+
? content.replace(marker, `${importLine}\n${registerLine}\n${marker}`)
|
|
139
|
+
: `${content.trimEnd()}\n${importLine}\n${registerLine}\n`;
|
|
140
|
+
fs.writeFileSync(appPath, content, "utf-8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function generateSwaLoginPage(): string {
|
|
144
|
+
return `'use client';
|
|
145
|
+
export default function LoginPage() {
|
|
146
|
+
return <main><h1>Sign in</h1><a href="/.auth/login/aad?post_login_redirect_uri=/">Sign in with Microsoft</a></main>;
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function generateSwaAuthContext(): string {
|
|
152
|
+
return `'use client';
|
|
153
|
+
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
|
154
|
+
|
|
155
|
+
export interface AuthUser { id: string; loginId: string; name: string; email: string; roles: string[]; }
|
|
156
|
+
interface AuthContextValue { user: AuthUser | null; loading: boolean; login: () => void; logout: () => void; hasRole: (role: string) => boolean; hasAnyRole: (roles: string[]) => boolean; }
|
|
157
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
158
|
+
|
|
159
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
160
|
+
const [user, setUser] = useState<AuthUser | null>(null);
|
|
161
|
+
const [loading, setLoading] = useState(true);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
fetch('/.auth/me')
|
|
164
|
+
.then(async response => {
|
|
165
|
+
if (!response.ok) throw new Error('SWA authentication is unavailable');
|
|
166
|
+
const body: unknown = await response.json();
|
|
167
|
+
if (!body || typeof body !== 'object' || !('clientPrincipal' in body)) return null;
|
|
168
|
+
const principal = body.clientPrincipal;
|
|
169
|
+
if (!principal || typeof principal !== 'object') return null;
|
|
170
|
+
const value = principal as Record<string, unknown>;
|
|
171
|
+
if (typeof value.userId !== 'string') return null;
|
|
172
|
+
return {
|
|
173
|
+
id: value.userId,
|
|
174
|
+
loginId: typeof value.userDetails === 'string' ? value.userDetails : '',
|
|
175
|
+
name: typeof value.userDetails === 'string' ? value.userDetails : '',
|
|
176
|
+
email: typeof value.userDetails === 'string' ? value.userDetails : '',
|
|
177
|
+
roles: Array.isArray(value.userRoles) ? value.userRoles.filter((role): role is string => typeof role === 'string') : [],
|
|
178
|
+
} satisfies AuthUser;
|
|
179
|
+
})
|
|
180
|
+
.then(setUser)
|
|
181
|
+
.catch(() => setUser(null))
|
|
182
|
+
.finally(() => setLoading(false));
|
|
183
|
+
}, []);
|
|
184
|
+
const login = () => { window.location.href = '/.auth/login/aad?post_login_redirect_uri=/'; };
|
|
185
|
+
const logout = () => { window.location.href = '/.auth/logout?post_logout_redirect_uri=/'; };
|
|
186
|
+
const hasRole = (role: string) => user?.roles.includes(role) ?? false;
|
|
187
|
+
return <AuthContext.Provider value={{ user, loading, login, logout, hasRole, hasAnyRole: roles => roles.some(hasRole) }}>{children}</AuthContext.Provider>;
|
|
188
|
+
}
|
|
189
|
+
export function useAuth() { const value = useContext(AuthContext); if (!value) throw new Error('useAuth must be used within an AuthProvider'); return value; }
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function addAuthProviderToRootLayout(layout: string): string {
|
|
194
|
+
let updated = layout;
|
|
195
|
+
if (!/import\s*\{[^}]*\bAuthProvider\b[^}]*\}\s*from\s*['"]@\/lib\/auth\/auth-context['"]/.test(updated)) {
|
|
196
|
+
const directive = updated.match(/^(?:\s*['"]use (?:client|server)['"];?\s*\r?\n)/)?.[0] ?? "";
|
|
197
|
+
updated = `${directive}import { AuthProvider } from '@/lib/auth/auth-context';\n${updated.slice(directive.length)}`;
|
|
198
|
+
}
|
|
199
|
+
if (!/<AuthProvider(?:\s|>)/.test(updated)) {
|
|
200
|
+
const childrenIndex = updated.lastIndexOf("{children}");
|
|
201
|
+
if (childrenIndex === -1) {
|
|
202
|
+
throw new Error("Could not find {children} in app/layout.tsx");
|
|
203
|
+
}
|
|
204
|
+
updated = `${updated.slice(0, childrenIndex)}<AuthProvider>{children}</AuthProvider>${updated.slice(childrenIndex + "{children}".length)}`;
|
|
205
|
+
}
|
|
206
|
+
return updated;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function updateRootLayoutWithAuthProvider(cwd: string): void {
|
|
210
|
+
const layoutPath = path.join(cwd, "app", "layout.tsx");
|
|
211
|
+
if (!fs.existsSync(layoutPath)) {
|
|
212
|
+
throw new Error("app/layout.tsx was not found; cannot install AuthProvider");
|
|
213
|
+
}
|
|
214
|
+
const layout = fs.readFileSync(layoutPath, "utf-8");
|
|
215
|
+
const updated = addAuthProviderToRootLayout(layout);
|
|
216
|
+
if (updated !== layout) fs.writeFileSync(layoutPath, updated, "utf-8");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function updateSwaRouteConfig(cwd: string): void {
|
|
220
|
+
const configPath = path.join(cwd, "staticwebapp.config.json");
|
|
221
|
+
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf-8")) : {};
|
|
222
|
+
config.routes = Array.isArray(config.routes)
|
|
223
|
+
? config.routes.filter((route: { route?: string }) => route.route !== "/api/*")
|
|
224
|
+
: [];
|
|
225
|
+
config.routes.unshift({ route: "/api/*", allowedRoles: ["authenticated"] });
|
|
226
|
+
config.responseOverrides = { ...(config.responseOverrides || {}), "401": { statusCode: 302, redirect: "/.auth/login/aad?post_login_redirect_uri=.referrer" } };
|
|
227
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
228
|
+
}
|
|
229
|
+
|
|
33
230
|
export async function addAuthCommand(options: AddAuthOptions) {
|
|
34
231
|
ensureSwallowKitProject("add-auth");
|
|
35
232
|
|
|
36
233
|
console.log(" SwallowKit Add-Auth: Setting up authentication...\n");
|
|
37
234
|
|
|
38
235
|
const provider = (options.provider || "custom-jwt") as AuthProvider;
|
|
39
|
-
if (!["custom-jwt", "swa", "swa-custom", "none"].includes(provider)) {
|
|
40
|
-
console.error(` Unknown provider: ${provider}. Use: custom-jwt | swa | swa-custom | none`);
|
|
236
|
+
if (!["custom-jwt", "swa", "external-token", "swa-custom", "none"].includes(provider)) {
|
|
237
|
+
console.error(` Unknown provider: ${provider}. Use: custom-jwt | swa | external-token | swa-custom | none`);
|
|
41
238
|
process.exit(1);
|
|
42
239
|
}
|
|
43
240
|
|
|
@@ -45,6 +242,31 @@ export async function addAuthCommand(options: AddAuthOptions) {
|
|
|
45
242
|
const config = getFullConfig();
|
|
46
243
|
const cwd = process.cwd();
|
|
47
244
|
|
|
245
|
+
if (provider === "swa-custom") {
|
|
246
|
+
throw new Error("The swa-custom provider is not implemented. Use --provider swa or --provider custom-jwt.");
|
|
247
|
+
}
|
|
248
|
+
if (provider === "none") {
|
|
249
|
+
updateConfigWithAuth(cwd, provider, config.auth?.customJwt || defaultCustomJwtConfig());
|
|
250
|
+
await syncProjectManifest();
|
|
251
|
+
console.log(" Authentication disabled in swallowkit.config.js");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (provider === "swa") {
|
|
255
|
+
setupSwaAuth(cwd, backendLanguage);
|
|
256
|
+
updateConfigWithAuth(cwd, provider, config.auth?.customJwt || defaultCustomJwtConfig());
|
|
257
|
+
await syncProjectManifest();
|
|
258
|
+
console.log("\n SWA built-in authentication setup complete!");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (provider === "external-token") {
|
|
262
|
+
setupExternalTokenAuth(cwd, backendLanguage);
|
|
263
|
+
updateConfigWithAuth(cwd, provider, config.auth?.customJwt || defaultCustomJwtConfig());
|
|
264
|
+
await syncProjectManifest();
|
|
265
|
+
console.log("\n External token authentication setup complete!");
|
|
266
|
+
console.log(" Implement the generated frontend adapter and backend verifier before use.");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
48
270
|
// Read project name from package.json
|
|
49
271
|
const pkgPath = path.join(cwd, "package.json");
|
|
50
272
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
@@ -59,15 +281,9 @@ export async function addAuthCommand(options: AddAuthOptions) {
|
|
|
59
281
|
}
|
|
60
282
|
|
|
61
283
|
// Default custom-jwt config
|
|
62
|
-
const customJwtConfig: CustomJwtConfig = config.auth?.customJwt ||
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
loginIdColumn: "login_id",
|
|
66
|
-
passwordHashColumn: "password_hash",
|
|
67
|
-
rolesColumn: "roles",
|
|
68
|
-
jwtSecretEnv: "JWT_SECRET",
|
|
69
|
-
tokenExpiry: "24h",
|
|
70
|
-
};
|
|
284
|
+
const customJwtConfig: CustomJwtConfig = config.auth?.customJwt || defaultCustomJwtConfig();
|
|
285
|
+
|
|
286
|
+
// custom-jwt setup continues below.
|
|
71
287
|
|
|
72
288
|
// 1. Generate shared/models/auth.ts
|
|
73
289
|
console.log(" Generating auth models...");
|
|
@@ -359,10 +575,7 @@ function updateConfigWithAuth(cwd: string, provider: AuthProvider, config: Custo
|
|
|
359
575
|
const beforeClosing = content.substring(0, closingBraceIdx).trimEnd();
|
|
360
576
|
const needsComma = !beforeClosing.endsWith(",") && !beforeClosing.endsWith("{");
|
|
361
577
|
|
|
362
|
-
const
|
|
363
|
-
// 認証認可設定
|
|
364
|
-
auth: {
|
|
365
|
-
provider: '${provider}',
|
|
578
|
+
const providerSettings = provider === "custom-jwt" ? `
|
|
366
579
|
customJwt: {
|
|
367
580
|
userConnector: '${config.userConnector}',
|
|
368
581
|
userTable: '${config.userTable}',
|
|
@@ -371,7 +584,17 @@ function updateConfigWithAuth(cwd: string, provider: AuthProvider, config: Custo
|
|
|
371
584
|
rolesColumn: '${config.rolesColumn}',
|
|
372
585
|
jwtSecretEnv: '${config.jwtSecretEnv || "JWT_SECRET"}',
|
|
373
586
|
tokenExpiry: '${config.tokenExpiry || "24h"}',
|
|
374
|
-
}
|
|
587
|
+
},` : provider === "swa" ? `
|
|
588
|
+
swa: {
|
|
589
|
+
allowedProviders: ['aad'],
|
|
590
|
+
roleSource: 'swa-roles',
|
|
591
|
+
},` : "";
|
|
592
|
+
|
|
593
|
+
const authBlock = `${needsComma ? "," : ""}
|
|
594
|
+
// 認証認可設定
|
|
595
|
+
auth: {
|
|
596
|
+
provider: '${provider}',
|
|
597
|
+
${providerSettings}
|
|
375
598
|
authorization: {
|
|
376
599
|
defaultPolicy: 'authenticated',
|
|
377
600
|
},
|