create-interview-cockpit 0.17.3 → 0.19.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.
@@ -0,0 +1,921 @@
1
+ import type { InfraLabWorkspace } from "./types";
2
+
3
+ export const ENTERPRISE_LOCAL_AUTH_LAB: InfraLabWorkspace = {
4
+ version: 1,
5
+ label: "Enterprise BFF Auth Stack",
6
+ provider: "docker",
7
+ executionMode: "docker",
8
+ activeFile: "README.md",
9
+ files: {
10
+ "README.md": `# Enterprise BFF Auth Stack
11
+
12
+ This lab is a local enterprise-style auth environment managed by Terraform.
13
+
14
+ ## What you deploy
15
+
16
+ - A local Cognito-like OIDC provider container
17
+ - A NestJS BFF container
18
+ - A NestJS claims API container
19
+ - A Redis session store container
20
+ - A private Docker network connecting the services
21
+
22
+ The browser only talks to the BFF. The BFF owns the auth code exchange, stores tokens server-side in Redis, and gives the browser an HttpOnly session cookie plus a readable CSRF cookie.
23
+ Terraform builds the local Node service images with the Docker CLI, then the Docker provider starts the containers.
24
+
25
+ ## Prerequisites
26
+
27
+ - Docker Desktop running
28
+ - Terraform installed locally
29
+
30
+ ## Run it from the Infra Lab console
31
+
32
+ 1. terraform init
33
+ 2. terraform plan -out=tfplan
34
+ 3. terraform apply -auto-approve
35
+ 4. terraform output
36
+ 5. Open the BFF URL from the output, usually http://localhost:4300
37
+
38
+ ## Practice flow
39
+
40
+ 1. Click Sign in.
41
+ 2. The browser goes to the local OIDC provider.
42
+ 3. The BFF receives the callback and exchanges the code.
43
+ 4. The BFF stores tokens in Redis.
44
+ 5. The browser receives only cookies.
45
+ 6. Fetch a claim through /api/claims/CLM-1001.
46
+ 7. Add a note; the BFF checks CSRF before forwarding to the claims API.
47
+
48
+ ## Separate Next.js client lab
49
+
50
+ Open **Labs → Next.js Labs → BFF Auth Client** to run a separate frontend shell.
51
+ That Next.js lab talks to this Terraform-deployed BFF at http://localhost:4300.
52
+
53
+ ## Interview talking point
54
+
55
+ This is intentionally close to a production BFF pattern: Authorization Code + PKCE, server-side session state, opaque browser cookies, CSRF protection for cookie-authenticated writes, and downstream API calls hidden behind the BFF.
56
+
57
+ ## Clean up
58
+
59
+ terraform destroy -auto-approve
60
+
61
+ If Terraform says the Docker network already exists, inspect and remove the stale empty network:
62
+
63
+ docker context show
64
+ docker network ls --filter name=enterprise-auth-lab
65
+ docker network inspect enterprise-auth-lab
66
+ docker network rm enterprise-auth-lab
67
+
68
+ If Terraform already manages the network and you want to keep it instead, import it:
69
+
70
+ terraform import docker_network.lab enterprise-auth-lab
71
+ `,
72
+ "provider.tf": `terraform {
73
+ required_version = ">= 1.5.0"
74
+
75
+ required_providers {
76
+ docker = {
77
+ source = "kreuzwerker/docker"
78
+ version = "~> 3.0"
79
+ }
80
+ }
81
+ }
82
+
83
+ provider "docker" {}
84
+ `,
85
+ "variables.tf": `variable "name_prefix" {
86
+ description = "Prefix used for Docker images, containers, and the network."
87
+ type = string
88
+ default = "enterprise-auth-lab"
89
+ }
90
+
91
+ variable "bff_port" {
92
+ description = "Host port for the NestJS BFF."
93
+ type = number
94
+ default = 4300
95
+ }
96
+
97
+ variable "idp_port" {
98
+ description = "Host port for the Cognito-like local OIDC provider."
99
+ type = number
100
+ default = 4400
101
+ }
102
+
103
+ variable "claims_port" {
104
+ description = "Optional host port for directly inspecting the downstream claims API."
105
+ type = number
106
+ default = 4500
107
+ }
108
+
109
+ variable "redis_port" {
110
+ description = "Optional host port for directly inspecting Redis. The app containers use the Docker network name."
111
+ type = number
112
+ default = 6379
113
+ }
114
+ `,
115
+ "main.tf": `locals {
116
+ bff_origin = "http://localhost:\${var.bff_port}"
117
+ idp_browser_origin = "http://localhost:\${var.idp_port}"
118
+
119
+ cognito_mock_image = "\${var.name_prefix}-cognito-mock:local"
120
+ claims_api_image = "\${var.name_prefix}-claims-api:local"
121
+ bff_image = "\${var.name_prefix}-bff:local"
122
+
123
+ cognito_mock_source_hash = sha1(join("", [for file_name in sort(fileset(path.module, "apps/cognito-mock/**")) : filesha1("\${path.module}/\${file_name}")]))
124
+ claims_api_source_hash = sha1(join("", [for file_name in sort(fileset(path.module, "apps/claims-api/**")) : filesha1("\${path.module}/\${file_name}")]))
125
+ bff_source_hash = sha1(join("", [for file_name in sort(fileset(path.module, "apps/bff/**")) : filesha1("\${path.module}/\${file_name}")]))
126
+ }
127
+
128
+ resource "docker_network" "lab" {
129
+ name = var.name_prefix
130
+ }
131
+
132
+ resource "docker_image" "redis" {
133
+ name = "redis:7-alpine"
134
+ keep_locally = true
135
+ }
136
+
137
+ resource "terraform_data" "cognito_mock_image" {
138
+ input = local.cognito_mock_image
139
+ triggers_replace = local.cognito_mock_source_hash
140
+
141
+ provisioner "local-exec" {
142
+ working_dir = path.module
143
+ command = "docker build -t \${local.cognito_mock_image} apps/cognito-mock"
144
+ }
145
+ }
146
+
147
+ resource "terraform_data" "claims_api_image" {
148
+ input = local.claims_api_image
149
+ triggers_replace = local.claims_api_source_hash
150
+
151
+ provisioner "local-exec" {
152
+ working_dir = path.module
153
+ command = "docker build -t \${local.claims_api_image} apps/claims-api"
154
+ }
155
+ }
156
+
157
+ resource "terraform_data" "bff_image" {
158
+ input = local.bff_image
159
+ triggers_replace = local.bff_source_hash
160
+
161
+ provisioner "local-exec" {
162
+ working_dir = path.module
163
+ command = "docker build -t \${local.bff_image} apps/bff"
164
+ }
165
+ }
166
+
167
+ resource "docker_container" "redis" {
168
+ name = "\${var.name_prefix}-redis"
169
+ image = docker_image.redis.image_id
170
+ wait = true
171
+
172
+ wait_timeout = 120
173
+
174
+ networks_advanced {
175
+ name = docker_network.lab.name
176
+ }
177
+
178
+ ports {
179
+ internal = 6379
180
+ external = var.redis_port
181
+ }
182
+
183
+ healthcheck {
184
+ test = ["CMD", "redis-cli", "ping"]
185
+ interval = "5s"
186
+ timeout = "3s"
187
+ start_period = "5s"
188
+ retries = 20
189
+ }
190
+ }
191
+
192
+ resource "docker_container" "cognito_mock" {
193
+ name = "\${var.name_prefix}-cognito-mock"
194
+ image = local.cognito_mock_image
195
+ wait = true
196
+
197
+ wait_timeout = 120
198
+
199
+ networks_advanced {
200
+ name = docker_network.lab.name
201
+ }
202
+
203
+ labels {
204
+ label = "interview-cockpit/source-hash"
205
+ value = local.cognito_mock_source_hash
206
+ }
207
+
208
+ ports {
209
+ internal = 4000
210
+ external = var.idp_port
211
+ }
212
+
213
+ env = [
214
+ "PORT=4000",
215
+ "CLIENT_ID=enterprise-bff",
216
+ "REDIRECT_URI=\${local.bff_origin}/auth/callback",
217
+ "ISSUER_BROWSER=\${local.idp_browser_origin}"
218
+ ]
219
+
220
+ healthcheck {
221
+ test = ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/.well-known/openid-configuration').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
222
+ interval = "5s"
223
+ timeout = "3s"
224
+ start_period = "5s"
225
+ retries = 20
226
+ }
227
+
228
+ depends_on = [terraform_data.cognito_mock_image]
229
+ }
230
+
231
+ resource "docker_container" "claims_api" {
232
+ name = "\${var.name_prefix}-claims-api"
233
+ image = local.claims_api_image
234
+ wait = true
235
+
236
+ wait_timeout = 120
237
+
238
+ networks_advanced {
239
+ name = docker_network.lab.name
240
+ }
241
+
242
+ labels {
243
+ label = "interview-cockpit/source-hash"
244
+ value = local.claims_api_source_hash
245
+ }
246
+
247
+ ports {
248
+ internal = 4000
249
+ external = var.claims_port
250
+ }
251
+
252
+ healthcheck {
253
+ test = ["CMD", "node", "-e", "fetch('http://127.0.0.1:4000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
254
+ interval = "5s"
255
+ timeout = "3s"
256
+ start_period = "5s"
257
+ retries = 20
258
+ }
259
+
260
+ depends_on = [terraform_data.claims_api_image]
261
+ }
262
+
263
+ resource "docker_container" "bff" {
264
+ name = "\${var.name_prefix}-bff"
265
+ image = local.bff_image
266
+ wait = true
267
+
268
+ wait_timeout = 120
269
+
270
+ networks_advanced {
271
+ name = docker_network.lab.name
272
+ }
273
+
274
+ labels {
275
+ label = "interview-cockpit/source-hash"
276
+ value = local.bff_source_hash
277
+ }
278
+
279
+ ports {
280
+ internal = 3000
281
+ external = var.bff_port
282
+ }
283
+
284
+ env = [
285
+ "PORT=3000",
286
+ "BFF_BASE_URL=\${local.bff_origin}",
287
+ "SESSION_COOKIE_NAME=local-session",
288
+ "COOKIE_SECURE=false",
289
+ "OIDC_CLIENT_ID=enterprise-bff",
290
+ "OIDC_REDIRECT_URI=\${local.bff_origin}/auth/callback",
291
+ "IDP_AUTHORIZE_URL=\${local.idp_browser_origin}/oauth2/authorize",
292
+ "IDP_TOKEN_URL=http://\${docker_container.cognito_mock.name}:4000/oauth2/token",
293
+ "IDP_USERINFO_URL=http://\${docker_container.cognito_mock.name}:4000/oauth2/userInfo",
294
+ "REDIS_URL=redis://\${docker_container.redis.name}:6379",
295
+ "CLAIMS_API_BASE_URL=http://\${docker_container.claims_api.name}:4000"
296
+ ]
297
+
298
+ healthcheck {
299
+ test = ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
300
+ interval = "5s"
301
+ timeout = "3s"
302
+ start_period = "5s"
303
+ retries = 20
304
+ }
305
+
306
+ depends_on = [
307
+ terraform_data.bff_image,
308
+ docker_container.redis,
309
+ docker_container.cognito_mock,
310
+ docker_container.claims_api
311
+ ]
312
+ }
313
+ `,
314
+ "outputs.tf": `output "bff_url" {
315
+ value = local.bff_origin
316
+ description = "Open this URL in the browser."
317
+ }
318
+
319
+ output "oidc_provider_url" {
320
+ value = local.idp_browser_origin
321
+ description = "Local Cognito-like authorize server."
322
+ }
323
+
324
+ output "claims_api_debug_url" {
325
+ value = "http://localhost:\${var.claims_port}"
326
+ description = "Direct downstream API URL for debugging. The browser app should still call the BFF."
327
+ }
328
+ `,
329
+ "apps/bff/Dockerfile": `FROM node:22-alpine
330
+ WORKDIR /app
331
+ COPY package*.json ./
332
+ RUN npm install
333
+ COPY tsconfig.json ./
334
+ COPY src ./src
335
+ RUN npm run build
336
+ EXPOSE 3000
337
+ CMD ["npm", "start"]
338
+ `,
339
+ "apps/bff/package.json": `{
340
+ "name": "enterprise-auth-bff",
341
+ "private": true,
342
+ "scripts": {
343
+ "build": "tsc -p tsconfig.json",
344
+ "start": "node dist/main.js"
345
+ },
346
+ "dependencies": {
347
+ "@nestjs/common": "^10.4.15",
348
+ "@nestjs/core": "^10.4.15",
349
+ "@nestjs/platform-express": "^10.4.15",
350
+ "cookie-parser": "^1.4.7",
351
+ "ioredis": "^5.4.2",
352
+ "reflect-metadata": "^0.2.2",
353
+ "rxjs": "^7.8.1"
354
+ },
355
+ "devDependencies": {
356
+ "@types/cookie-parser": "^1.4.8",
357
+ "@types/express": "^4.17.21",
358
+ "@types/node": "^22.0.0",
359
+ "typescript": "^5.6.0"
360
+ }
361
+ }
362
+ `,
363
+ "apps/bff/tsconfig.json": `{
364
+ "compilerOptions": {
365
+ "target": "ES2022",
366
+ "module": "CommonJS",
367
+ "moduleResolution": "Node",
368
+ "experimentalDecorators": true,
369
+ "emitDecoratorMetadata": true,
370
+ "esModuleInterop": true,
371
+ "strict": false,
372
+ "skipLibCheck": true,
373
+ "outDir": "dist",
374
+ "rootDir": "src"
375
+ },
376
+ "include": ["src/**/*.ts"]
377
+ }
378
+ `,
379
+ "apps/bff/src/main.ts": `import "reflect-metadata";
380
+ import { Body, Controller, Get, Headers, Module, Param, Post, Req, Res } from "@nestjs/common";
381
+ import { NestFactory } from "@nestjs/core";
382
+ import cookieParser from "cookie-parser";
383
+ import Redis from "ioredis";
384
+ import crypto from "crypto";
385
+ import type { Request, Response } from "express";
386
+
387
+ type TokenSet = {
388
+ access_token: string;
389
+ id_token?: string;
390
+ refresh_token?: string;
391
+ expires_in?: number;
392
+ };
393
+
394
+ type SessionRecord = {
395
+ user: { sub: string; email?: string; name?: string };
396
+ tokens: TokenSet & { expiresAt: number };
397
+ csrfToken: string;
398
+ };
399
+
400
+ type LoginChallenge = {
401
+ state: string;
402
+ nonce: string;
403
+ verifier: string;
404
+ returnTo: string;
405
+ };
406
+
407
+ const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
408
+ const cookieSecure = process.env.COOKIE_SECURE === "true";
409
+ const sessionCookie = process.env.SESSION_COOKIE_NAME || (cookieSecure ? "__Host-session" : "local-session");
410
+ const loginCookie = "login-challenge";
411
+ const clientId = process.env.OIDC_CLIENT_ID || "enterprise-bff";
412
+ const redirectUri = process.env.OIDC_REDIRECT_URI || "http://localhost:4300/auth/callback";
413
+ const bffBaseUrl = process.env.BFF_BASE_URL || "http://localhost:4300";
414
+ const claimsBaseUrl = process.env.CLAIMS_API_BASE_URL || "http://localhost:4500";
415
+
416
+ function randomToken(bytes = 32) {
417
+ return crypto.randomBytes(bytes).toString("base64url");
418
+ }
419
+
420
+ function pkceChallenge(verifier: string) {
421
+ return crypto.createHash("sha256").update(verifier).digest("base64url");
422
+ }
423
+
424
+ function cookieOptions(httpOnly: boolean, maxAge: number) {
425
+ return { httpOnly, secure: cookieSecure, sameSite: "lax" as const, path: "/", maxAge };
426
+ }
427
+
428
+ function isAllowedLocalOrigin(origin: string | undefined): boolean {
429
+ if (!origin) return true;
430
+ try {
431
+ const url = new URL(origin);
432
+ return (
433
+ (url.hostname === "localhost" || url.hostname === "127.0.0.1") &&
434
+ (url.protocol === "http:" || url.protocol === "https:")
435
+ );
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
440
+
441
+ function safeReturnTo(value: unknown): string {
442
+ const candidate = typeof value === "string" ? value : "";
443
+ if (!candidate) return bffBaseUrl + "/";
444
+ try {
445
+ const url = new URL(candidate);
446
+ if (!isAllowedLocalOrigin(url.origin)) return bffBaseUrl + "/";
447
+ return url.toString();
448
+ } catch {
449
+ return bffBaseUrl + "/";
450
+ }
451
+ }
452
+
453
+ async function loadSession(req: Request): Promise<{ id: string; value: SessionRecord } | null> {
454
+ const id = String((req as any).cookies?.[sessionCookie] || "");
455
+ if (!id) return null;
456
+ const raw = await redis.get("sess:" + id);
457
+ if (!raw) return null;
458
+ return { id, value: JSON.parse(raw) as SessionRecord };
459
+ }
460
+
461
+ async function requireSession(req: Request, res: Response): Promise<SessionRecord | null> {
462
+ const session = await loadSession(req);
463
+ if (!session) {
464
+ res.status(401).json({ error: "Unauthorized" });
465
+ return null;
466
+ }
467
+ await redis.expire("sess:" + session.id, 30 * 60);
468
+ return session.value;
469
+ }
470
+
471
+ function assertCsrf(req: Request, res: Response, session: SessionRecord): boolean {
472
+ const token = String(req.headers["x-csrf-token"] || "");
473
+ if (!token || token !== session.csrfToken) {
474
+ res.status(403).json({ error: "Invalid CSRF token" });
475
+ return false;
476
+ }
477
+ return true;
478
+ }
479
+
480
+ function homeHtml() {
481
+ return [
482
+ "<!doctype html><html><head><meta charset='utf-8'><title>Enterprise BFF Lab</title>",
483
+ "<style>body{font-family:Inter,system-ui;background:#020617;color:#e2e8f0;margin:0;padding:32px}button{background:#06b6d4;border:0;border-radius:8px;padding:10px 14px;font-weight:700}pre,textarea{width:100%;box-sizing:border-box;background:#0f172a;color:#cbd5e1;border:1px solid #334155;border-radius:10px;padding:12px}section{max-width:900px;margin:auto}.card{border:1px solid #1e293b;border-radius:16px;padding:20px;margin:16px 0;background:#0f172a80}.muted{color:#94a3b8}</style></head><body><section>",
484
+ "<h1>Enterprise BFF Auth Lab</h1><p class='muted'>Browser stores cookies only. The BFF owns tokens and downstream API calls.</p>",
485
+ "<div class='card'><button onclick='login()'>Sign in</button> <button onclick='logout()'>Logout</button> <button onclick='me()'>/auth/me</button></div>",
486
+ "<div class='card'><button onclick='claim()'>Fetch claim CLM-1001 through BFF</button><pre id='out'>Ready.</pre></div>",
487
+ "<div class='card'><textarea id='note' rows='3'>Customer uploaded police report.</textarea><br><br><button onclick='addNote()'>Add note with CSRF header</button></div>",
488
+ "<script>",
489
+ "function login(){ location.href='/auth/login'; }",
490
+ "async function logout(){ await api('/auth/logout',{method:'POST'}); out('Logged out'); }",
491
+ "function csrf(){ const row=document.cookie.split('; ').find(x=>x.startsWith('XSRF-TOKEN=')); return row ? decodeURIComponent(row.split('=')[1]) : ''; }",
492
+ "async function api(path, init){ init=init||{}; const headers=new Headers(init.headers||{}); const method=(init.method||'GET').toUpperCase(); if(!['GET','HEAD','OPTIONS'].includes(method)){ headers.set('x-csrf-token', csrf()); } const res=await fetch(path,Object.assign({},init,{headers:headers,credentials:'include'})); const text=await res.text(); try{return {status:res.status, body:JSON.parse(text)}}catch{return {status:res.status, body:text}} }",
493
+ "function out(v){ document.getElementById('out').textContent=typeof v==='string'?v:JSON.stringify(v,null,2); }",
494
+ "async function me(){ out(await api('/auth/me')); }",
495
+ "async function claim(){ out(await api('/api/claims/CLM-1001')); }",
496
+ "async function addNote(){ out(await api('/api/claims/CLM-1001/notes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({text:document.getElementById('note').value})})); }",
497
+ "me().then(out);",
498
+ "</script></section></body></html>"
499
+ ].join("");
500
+ }
501
+
502
+ @Controller()
503
+ class HomeController {
504
+ @Get()
505
+ home(@Res() res: Response) {
506
+ res.type("html").send(homeHtml());
507
+ }
508
+ }
509
+
510
+ @Controller("auth")
511
+ class AuthController {
512
+ @Get("login")
513
+ async login(@Req() req: Request, @Res() res: Response) {
514
+ const state = randomToken(24);
515
+ const nonce = randomToken(24);
516
+ const verifier = randomToken(48);
517
+ const loginId = randomToken(24);
518
+ const returnTo = safeReturnTo(req.query.returnTo);
519
+ await redis.setex("login:" + loginId, 300, JSON.stringify({ state, nonce, verifier, returnTo }));
520
+
521
+ const authorizeUrl = new URL(process.env.IDP_AUTHORIZE_URL || "http://localhost:4400/oauth2/authorize");
522
+ authorizeUrl.search = new URLSearchParams({
523
+ response_type: "code",
524
+ client_id: clientId,
525
+ redirect_uri: redirectUri,
526
+ scope: "openid email profile",
527
+ state,
528
+ nonce,
529
+ code_challenge: pkceChallenge(verifier),
530
+ code_challenge_method: "S256"
531
+ }).toString();
532
+
533
+ res.cookie(loginCookie, loginId, cookieOptions(true, 5 * 60 * 1000));
534
+ res.redirect(authorizeUrl.toString());
535
+ }
536
+
537
+ @Get("callback")
538
+ async callback(@Req() req: Request, @Res() res: Response) {
539
+ const code = String(req.query.code || "");
540
+ const state = String(req.query.state || "");
541
+ const loginId = String((req as any).cookies?.[loginCookie] || "");
542
+ const rawLogin = loginId ? await redis.get("login:" + loginId) : null;
543
+ if (!code || !rawLogin) return res.status(400).send("Missing login session");
544
+ const login = JSON.parse(rawLogin) as LoginChallenge;
545
+ if (state !== login.state) return res.status(400).send("Invalid state");
546
+
547
+ const tokenRes = await fetch(process.env.IDP_TOKEN_URL || "http://localhost:4400/oauth2/token", {
548
+ method: "POST",
549
+ headers: { "content-type": "application/x-www-form-urlencoded" },
550
+ body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, code_verifier: login.verifier })
551
+ });
552
+ if (!tokenRes.ok) return res.status(502).send(await tokenRes.text());
553
+ const tokens = await tokenRes.json() as TokenSet;
554
+
555
+ const userRes = await fetch(process.env.IDP_USERINFO_URL || "http://localhost:4400/oauth2/userInfo", {
556
+ headers: { authorization: "Bearer " + tokens.access_token }
557
+ });
558
+ const user = await userRes.json() as { sub: string; email?: string; name?: string };
559
+ const sessionId = randomToken(32);
560
+ const csrfToken = randomToken(32);
561
+ const session: SessionRecord = {
562
+ user,
563
+ csrfToken,
564
+ tokens: { ...tokens, expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in || 900) }
565
+ };
566
+
567
+ await redis.del("login:" + loginId);
568
+ await redis.setex("sess:" + sessionId, 30 * 60, JSON.stringify(session));
569
+ res.clearCookie(loginCookie, { path: "/" });
570
+ res.cookie(sessionCookie, sessionId, cookieOptions(true, 30 * 60 * 1000));
571
+ res.cookie("XSRF-TOKEN", csrfToken, cookieOptions(false, 30 * 60 * 1000));
572
+ res.redirect(login.returnTo || bffBaseUrl + "/");
573
+ }
574
+
575
+ @Get("me")
576
+ async me(@Req() req: Request, @Res() res: Response) {
577
+ const session = await loadSession(req);
578
+ if (!session) return res.status(401).json({ authenticated: false });
579
+ res.json({ authenticated: true, user: session.value.user });
580
+ }
581
+
582
+ @Post("logout")
583
+ async logout(@Req() req: Request, @Res() res: Response) {
584
+ const session = await loadSession(req);
585
+ if (session && !assertCsrf(req, res, session.value)) return;
586
+ if (session) await redis.del("sess:" + session.id);
587
+ res.clearCookie(sessionCookie, { path: "/" });
588
+ res.clearCookie("XSRF-TOKEN", { path: "/" });
589
+ res.status(204).send();
590
+ }
591
+ }
592
+
593
+ @Controller("api/claims")
594
+ class ClaimsController {
595
+ @Get(":claimId")
596
+ async getClaim(@Param("claimId") claimId: string, @Req() req: Request, @Res() res: Response) {
597
+ const session = await requireSession(req, res);
598
+ if (!session) return;
599
+ const upstream = await fetch(claimsBaseUrl + "/claims/" + encodeURIComponent(claimId), {
600
+ headers: { authorization: "Bearer " + session.tokens.access_token, "x-correlation-id": crypto.randomUUID() }
601
+ });
602
+ res.status(upstream.status).send(await upstream.text());
603
+ }
604
+
605
+ @Post(":claimId/notes")
606
+ async addNote(@Param("claimId") claimId: string, @Body() body: unknown, @Req() req: Request, @Res() res: Response) {
607
+ const session = await requireSession(req, res);
608
+ if (!session || !assertCsrf(req, res, session)) return;
609
+ const upstream = await fetch(claimsBaseUrl + "/claims/" + encodeURIComponent(claimId) + "/notes", {
610
+ method: "POST",
611
+ headers: { authorization: "Bearer " + session.tokens.access_token, "content-type": "application/json", "x-correlation-id": crypto.randomUUID() },
612
+ body: JSON.stringify(body)
613
+ });
614
+ res.status(upstream.status).send(await upstream.text());
615
+ }
616
+ }
617
+
618
+ @Module({ controllers: [HomeController, AuthController, ClaimsController] })
619
+ class AppModule {}
620
+
621
+ async function bootstrap() {
622
+ const app = await NestFactory.create(AppModule, { logger: ["error", "warn", "log"] });
623
+ app.enableCors({
624
+ origin(origin, callback) {
625
+ if (isAllowedLocalOrigin(origin)) return callback(null, true);
626
+ callback(new Error("Origin not allowed by local BFF lab CORS policy"), false);
627
+ },
628
+ credentials: true,
629
+ methods: ["GET", "POST", "OPTIONS"],
630
+ allowedHeaders: ["content-type", "x-csrf-token"],
631
+ });
632
+ app.use(cookieParser());
633
+ await app.listen(Number(process.env.PORT || 3000), "0.0.0.0");
634
+ }
635
+
636
+ bootstrap();
637
+ `,
638
+ "apps/claims-api/Dockerfile": `FROM node:22-alpine
639
+ WORKDIR /app
640
+ COPY package*.json ./
641
+ RUN npm install
642
+ COPY tsconfig.json ./
643
+ COPY src ./src
644
+ RUN npm run build
645
+ EXPOSE 4000
646
+ CMD ["npm", "start"]
647
+ `,
648
+ "apps/claims-api/package.json": `{
649
+ "name": "claims-api",
650
+ "private": true,
651
+ "scripts": {
652
+ "build": "tsc -p tsconfig.json",
653
+ "start": "node dist/main.js"
654
+ },
655
+ "dependencies": {
656
+ "@nestjs/common": "^10.4.15",
657
+ "@nestjs/core": "^10.4.15",
658
+ "@nestjs/platform-express": "^10.4.15",
659
+ "reflect-metadata": "^0.2.2",
660
+ "rxjs": "^7.8.1"
661
+ },
662
+ "devDependencies": {
663
+ "@types/express": "^4.17.21",
664
+ "@types/node": "^22.0.0",
665
+ "typescript": "^5.6.0"
666
+ }
667
+ }
668
+ `,
669
+ "apps/claims-api/tsconfig.json": `{
670
+ "compilerOptions": {
671
+ "target": "ES2022",
672
+ "module": "CommonJS",
673
+ "moduleResolution": "Node",
674
+ "experimentalDecorators": true,
675
+ "emitDecoratorMetadata": true,
676
+ "esModuleInterop": true,
677
+ "strict": false,
678
+ "skipLibCheck": true,
679
+ "outDir": "dist",
680
+ "rootDir": "src"
681
+ },
682
+ "include": ["src/**/*.ts"]
683
+ }
684
+ `,
685
+ "apps/claims-api/src/main.ts": `import "reflect-metadata";
686
+ import { Body, Controller, Get, Headers, Module, Param, Post, Res } from "@nestjs/common";
687
+ import { NestFactory } from "@nestjs/core";
688
+ import type { Response } from "express";
689
+
690
+ const notes: Record<string, Array<{ text: string; createdAt: string }>> = {};
691
+
692
+ function isAuthorized(value?: string) {
693
+ return Boolean(value && value.startsWith("Bearer "));
694
+ }
695
+
696
+ @Controller()
697
+ class HealthController {
698
+ @Get("health")
699
+ health() {
700
+ return { ok: true, service: "claims-api" };
701
+ }
702
+ }
703
+
704
+ @Controller("claims")
705
+ class ClaimsController {
706
+ @Get(":claimId")
707
+ getClaim(@Param("claimId") claimId: string, @Headers("authorization") authorization: string | undefined, @Res() res: Response) {
708
+ if (!isAuthorized(authorization)) return res.status(401).json({ error: "Missing bearer token" });
709
+ res.json({
710
+ id: claimId,
711
+ status: "IN_REVIEW",
712
+ policyNumber: "POL-8842-UK",
713
+ insuredPerson: "Ava Mensah",
714
+ lossDate: "2026-05-20",
715
+ notes: notes[claimId] || []
716
+ });
717
+ }
718
+
719
+ @Post(":claimId/notes")
720
+ addNote(@Param("claimId") claimId: string, @Body() body: { text?: string }, @Headers("authorization") authorization: string | undefined, @Res() res: Response) {
721
+ if (!isAuthorized(authorization)) return res.status(401).json({ error: "Missing bearer token" });
722
+ const note = { text: String(body?.text || ""), createdAt: new Date().toISOString() };
723
+ notes[claimId] = [...(notes[claimId] || []), note];
724
+ res.status(201).json({ claimId, note });
725
+ }
726
+ }
727
+
728
+ @Module({ controllers: [HealthController, ClaimsController] })
729
+ class AppModule {}
730
+
731
+ async function bootstrap() {
732
+ const app = await NestFactory.create(AppModule, { logger: ["error", "warn", "log"] });
733
+ await app.listen(Number(process.env.PORT || 4000), "0.0.0.0");
734
+ }
735
+
736
+ bootstrap();
737
+ `,
738
+ "apps/cognito-mock/Dockerfile": `FROM node:22-alpine
739
+ WORKDIR /app
740
+ COPY package*.json ./
741
+ RUN npm install
742
+ COPY tsconfig.json ./
743
+ COPY src ./src
744
+ RUN npm run build
745
+ EXPOSE 4000
746
+ CMD ["npm", "start"]
747
+ `,
748
+ "apps/cognito-mock/package.json": `{
749
+ "name": "local-cognito-mock",
750
+ "private": true,
751
+ "scripts": {
752
+ "build": "tsc -p tsconfig.json",
753
+ "start": "node dist/main.js"
754
+ },
755
+ "dependencies": {
756
+ "@nestjs/common": "^10.4.15",
757
+ "@nestjs/core": "^10.4.15",
758
+ "@nestjs/platform-express": "^10.4.15",
759
+ "express": "^4.21.0",
760
+ "reflect-metadata": "^0.2.2",
761
+ "rxjs": "^7.8.1"
762
+ },
763
+ "devDependencies": {
764
+ "@types/express": "^4.17.21",
765
+ "@types/node": "^22.0.0",
766
+ "typescript": "^5.6.0"
767
+ }
768
+ }
769
+ `,
770
+ "apps/cognito-mock/tsconfig.json": `{
771
+ "compilerOptions": {
772
+ "target": "ES2022",
773
+ "module": "CommonJS",
774
+ "moduleResolution": "Node",
775
+ "experimentalDecorators": true,
776
+ "emitDecoratorMetadata": true,
777
+ "esModuleInterop": true,
778
+ "strict": false,
779
+ "skipLibCheck": true,
780
+ "outDir": "dist",
781
+ "rootDir": "src"
782
+ },
783
+ "include": ["src/**/*.ts"]
784
+ }
785
+ `,
786
+ "apps/cognito-mock/src/main.ts": `import "reflect-metadata";
787
+ import { Body, Controller, Get, Module, Post, Query, Req, Res } from "@nestjs/common";
788
+ import { NestFactory } from "@nestjs/core";
789
+ import crypto from "crypto";
790
+ import express from "express";
791
+ import type { Request, Response } from "express";
792
+
793
+ type AuthCode = {
794
+ clientId: string;
795
+ redirectUri: string;
796
+ scope: string;
797
+ nonce: string;
798
+ codeChallenge: string;
799
+ expiresAt: number;
800
+ };
801
+
802
+ const codes = new Map<string, AuthCode>();
803
+ const issuer = process.env.ISSUER_BROWSER || "http://localhost:4400";
804
+ const expectedClientId = process.env.CLIENT_ID || "enterprise-bff";
805
+ const expectedRedirectUri = process.env.REDIRECT_URI || "http://localhost:4300/auth/callback";
806
+ const demoUser = { sub: "user-123", email: "ava.adjuster@example.com", name: "Ava Adjuster" };
807
+
808
+ function randomToken(bytes = 32) {
809
+ return crypto.randomBytes(bytes).toString("base64url");
810
+ }
811
+
812
+ function sha256Base64Url(value: string) {
813
+ return crypto.createHash("sha256").update(value).digest("base64url");
814
+ }
815
+
816
+ function jsonPart(value: unknown) {
817
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
818
+ }
819
+
820
+ function unsignedJwt(payload: Record<string, unknown>) {
821
+ return jsonPart({ alg: "none", typ: "JWT" }) + "." + jsonPart(payload) + ".";
822
+ }
823
+
824
+ function decodeJwt(token: string) {
825
+ const part = token.split(".")[1] || "";
826
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
827
+ }
828
+
829
+ @Controller(".well-known")
830
+ class DiscoveryController {
831
+ @Get("openid-configuration")
832
+ discovery() {
833
+ return {
834
+ issuer,
835
+ authorization_endpoint: issuer + "/oauth2/authorize",
836
+ token_endpoint: issuer + "/oauth2/token",
837
+ userinfo_endpoint: issuer + "/oauth2/userInfo",
838
+ response_types_supported: ["code"],
839
+ grant_types_supported: ["authorization_code", "refresh_token"],
840
+ code_challenge_methods_supported: ["S256"]
841
+ };
842
+ }
843
+ }
844
+
845
+ @Controller("oauth2")
846
+ class OAuthController {
847
+ @Get("authorize")
848
+ authorize(@Query() query: Record<string, string>, @Res() res: Response) {
849
+ const clientId = String(query.client_id || "");
850
+ const redirectUri = String(query.redirect_uri || "");
851
+ const state = String(query.state || "");
852
+ const scope = String(query.scope || "openid email profile");
853
+ const nonce = String(query.nonce || "");
854
+ const codeChallenge = String(query.code_challenge || "");
855
+ if (clientId !== expectedClientId || redirectUri !== expectedRedirectUri || !state || !codeChallenge) {
856
+ return res.status(400).send("Invalid authorize request");
857
+ }
858
+
859
+ if (query.approve !== "1") {
860
+ const approve = new URL("/oauth2/authorize", issuer);
861
+ Object.entries(query).forEach(([key, value]) => approve.searchParams.set(key, String(value)));
862
+ approve.searchParams.set("approve", "1");
863
+ return res.type("html").send([
864
+ "<!doctype html><html><head><title>Local Cognito</title><style>body{font-family:Inter,system-ui;background:#111827;color:#f8fafc;padding:40px}main{max-width:640px;margin:auto;border:1px solid #334155;border-radius:16px;padding:24px;background:#0f172a}a{display:inline-block;background:#22c55e;color:#052e16;border-radius:8px;padding:12px 16px;text-decoration:none;font-weight:800}.muted{color:#94a3b8}</style></head><body><main>",
865
+ "<h1>Local Cognito Hosted UI</h1><p class='muted'>This mock approves a demo user so you can practice the BFF flow locally.</p>",
866
+ "<p>User: Ava Adjuster &lt;ava.adjuster@example.com&gt;</p><a href='" + approve.toString() + "'>Sign in as Ava</a>",
867
+ "</main></body></html>"
868
+ ].join(""));
869
+ }
870
+
871
+ const code = randomToken(24);
872
+ codes.set(code, { clientId, redirectUri, scope, nonce, codeChallenge, expiresAt: Date.now() + 5 * 60 * 1000 });
873
+ const callback = new URL(redirectUri);
874
+ callback.searchParams.set("code", code);
875
+ callback.searchParams.set("state", state);
876
+ res.redirect(callback.toString());
877
+ }
878
+
879
+ @Post("token")
880
+ token(@Body() body: Record<string, string>, @Res() res: Response) {
881
+ const code = String(body.code || "");
882
+ const record = codes.get(code);
883
+ if (!record || record.expiresAt < Date.now()) return res.status(400).json({ error: "invalid_grant" });
884
+ if (body.client_id !== record.clientId || body.redirect_uri !== record.redirectUri) return res.status(400).json({ error: "invalid_request" });
885
+ if (sha256Base64Url(String(body.code_verifier || "")) !== record.codeChallenge) return res.status(400).json({ error: "invalid_grant", error_description: "PKCE verification failed" });
886
+ codes.delete(code);
887
+
888
+ const now = Math.floor(Date.now() / 1000);
889
+ const common = { iss: issuer, aud: record.clientId, iat: now, exp: now + 900, scope: record.scope, ...demoUser };
890
+ res.json({
891
+ token_type: "Bearer",
892
+ expires_in: 900,
893
+ access_token: unsignedJwt(common),
894
+ id_token: unsignedJwt({ ...common, nonce: record.nonce }),
895
+ refresh_token: randomToken(32),
896
+ scope: record.scope
897
+ });
898
+ }
899
+
900
+ @Get("userInfo")
901
+ userInfo(@Req() req: Request, @Res() res: Response) {
902
+ const token = String(req.headers.authorization || "").replace(/^Bearer\s+/i, "");
903
+ if (!token) return res.status(401).json({ error: "missing_token" });
904
+ const payload = decodeJwt(token);
905
+ res.json({ sub: payload.sub, email: payload.email, name: payload.name });
906
+ }
907
+ }
908
+
909
+ @Module({ controllers: [DiscoveryController, OAuthController] })
910
+ class AppModule {}
911
+
912
+ async function bootstrap() {
913
+ const app = await NestFactory.create(AppModule, { logger: ["error", "warn", "log"] });
914
+ app.use(express.urlencoded({ extended: false }));
915
+ await app.listen(Number(process.env.PORT || 4000), "0.0.0.0");
916
+ }
917
+
918
+ bootstrap();
919
+ `,
920
+ },
921
+ };