create-interview-cockpit 0.17.3 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +83 -8
- package/template/client/src/components/GithubActionsLabModal.tsx +746 -0
- package/template/client/src/components/InfraLabModal.tsx +993 -262
- package/template/client/src/components/LabsPanel.tsx +71 -5
- package/template/client/src/components/Sidebar.tsx +400 -14
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/enterpriseLocalLab.ts +921 -0
- package/template/client/src/githubActionsLab.ts +287 -0
- package/template/client/src/infraLab.ts +378 -6
- package/template/client/src/reactLab.ts +409 -0
- package/template/client/src/store.ts +83 -10
- package/template/client/src/types.ts +27 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/gha-runner.ts +468 -0
- package/template/server/src/google-drive.ts +35 -24
- package/template/server/src/index.ts +241 -10
- package/template/server/src/infra-runner.ts +321 -30
- package/template/server/src/storage.ts +3 -1
|
@@ -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 <ava.adjuster@example.com></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
|
+
};
|