create-authhero 0.43.0 → 0.45.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.
@@ -133,6 +133,28 @@ Enable auto-scaling or increase provisioned capacity in `sst.config.ts`.
133
133
 
134
134
  Ensure the S3 bucket and CloudFront are properly configured. Check browser console for CORS errors.
135
135
 
136
+ ## Encryption at rest
137
+
138
+ Sensitive credential fields (client secrets, connection secrets, email
139
+ credentials, TOTP secrets, migration-source secrets) are encrypted at rest.
140
+ A random `ENCRYPTION_KEY` was generated into `.env` when this project was
141
+ created. `sst.config.ts` forwards it to the Lambda, and `src/index.ts` enables
142
+ encryption whenever the key is present.
143
+
144
+ > **The key is load-bearing.** If you delete, rotate, or lose `ENCRYPTION_KEY`,
145
+ > any values already encrypted with it become unreadable. Treat the key as a
146
+ > long-lived secret, back it up, and use a separate key per deployment stage.
147
+
148
+ For production, set `ENCRYPTION_KEY` in your deploy environment / secret store
149
+ rather than reusing the generated dev key.
150
+
151
+ Helper scripts:
152
+
153
+ ```bash
154
+ npm run gen:key # print a fresh base64 key
155
+ npm run decrypt -- "enc:v1:..." # decrypt a stored value using ENCRYPTION_KEY from .env
156
+ ```
157
+
136
158
  ## Learn More
137
159
 
138
160
  - [SST Documentation](https://sst.dev/docs)
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { loadEncryptionKey, decryptField } from "authhero";
3
+
4
+ // Decrypt a stored field value using ENCRYPTION_KEY from the environment.
5
+ // Usage: node --env-file=.env scripts/decrypt-field.mjs "enc:v1:..."
6
+ // Values without the enc:v1: prefix (legacy plaintext) are printed unchanged.
7
+ const value = process.argv[2];
8
+
9
+ if (!value) {
10
+ console.error(
11
+ 'Usage: node --env-file=<env> scripts/decrypt-field.mjs "<value>"',
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ const keyB64 = process.env.ENCRYPTION_KEY;
17
+ if (!keyB64) {
18
+ console.error(
19
+ "ENCRYPTION_KEY is not set. Pass it via --env-file or the environment.",
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ try {
25
+ const key = await loadEncryptionKey(keyB64);
26
+ console.log(await decryptField(value, key));
27
+ } catch (error) {
28
+ console.error(
29
+ "Failed to decrypt:",
30
+ error instanceof Error ? error.message : error,
31
+ );
32
+ process.exit(1);
33
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+
4
+ // Print a fresh base64-encoded 32-byte (AES-256) key suitable for
5
+ // ENCRYPTION_KEY. Copy the output into your env file (.env / .dev.vars) or set
6
+ // it as a production secret.
7
+ console.log(crypto.randomBytes(32).toString("base64"));
@@ -2,6 +2,11 @@ import { handle } from "hono/aws-lambda";
2
2
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3
3
  import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
4
4
  import createAdapters from "@authhero/aws";
5
+ import {
6
+ DataAdapters,
7
+ createEncryptedDataAdapter,
8
+ loadEncryptionKey,
9
+ } from "authhero";
5
10
  import createApp from "./app";
6
11
  import type { APIGatewayProxyEventV2, Context } from "aws-lambda";
7
12
 
@@ -23,6 +28,22 @@ const dataAdapter = createAdapters(docClient, {
23
28
  tableName: process.env.TABLE_NAME,
24
29
  });
25
30
 
31
+ // Encrypt sensitive credential fields at rest when ENCRYPTION_KEY is set.
32
+ // The wrapped adapter is built once and cached across warm invocations.
33
+ let encryptedAdapterPromise: Promise<DataAdapters> | null = null;
34
+ async function getDataAdapter(): Promise<DataAdapters> {
35
+ if (!process.env.ENCRYPTION_KEY) {
36
+ return dataAdapter;
37
+ }
38
+ if (!encryptedAdapterPromise) {
39
+ const rawKey = process.env.ENCRYPTION_KEY;
40
+ encryptedAdapterPromise = loadEncryptionKey(rawKey).then((key) =>
41
+ createEncryptedDataAdapter(dataAdapter, key),
42
+ );
43
+ }
44
+ return encryptedAdapterPromise;
45
+ }
46
+
26
47
  export async function handler(event: APIGatewayProxyEventV2, context: Context) {
27
48
  // Compute issuer from the request
28
49
  const host = event.headers.host || event.requestContext.domainName;
@@ -51,7 +72,7 @@ export async function handler(event: APIGatewayProxyEventV2, context: Context) {
51
72
  // Create app instance per request to avoid issuer contamination
52
73
  // Lambda containers are reused, so we can't mutate process.env.ISSUER globally
53
74
  const appWithIssuer = createApp({
54
- dataAdapter,
75
+ dataAdapter: await getDataAdapter(),
55
76
  allowedOrigins,
56
77
  widgetUrl,
57
78
  });
@@ -55,6 +55,10 @@ export default $config({
55
55
  environment: {
56
56
  TABLE_NAME: table.name,
57
57
  WIDGET_URL: assets.url,
58
+ // At-rest encryption key for sensitive credentials. Sourced from the
59
+ // environment (e.g. a generated .env loaded by SST, or your CI/secret
60
+ // store). Encryption is skipped when this is empty.
61
+ ENCRYPTION_KEY: process.env.ENCRYPTION_KEY ?? "",
58
62
  },
59
63
  nodejs: {
60
64
  install: ["@authhero/aws"],
@@ -5,6 +5,16 @@
5
5
  # The .dev.vars file is used by wrangler for local development.
6
6
  # ============================================================================
7
7
 
8
+ # ============================================================================
9
+ # Encryption at rest
10
+ # ============================================================================
11
+ # Base64-encoded 32-byte key used to encrypt sensitive credential fields
12
+ # (client secrets, connection secrets, email credentials, TOTP secrets, etc.).
13
+ # `create-authhero` writes a generated key into .dev.vars for local dev.
14
+ # In production, set it as a secret instead: `wrangler secret put ENCRYPTION_KEY`.
15
+ # Generate one with: openssl rand -base64 32
16
+ # ENCRYPTION_KEY=
17
+
8
18
  # ============================================================================
9
19
  # OPTIONAL: Analytics Engine Configuration
10
20
  # ============================================================================
@@ -404,3 +404,28 @@ Cloudflare Rate Limiting helps protect your authentication endpoints from abuse.
404
404
  | `limit` | Max requests allowed | `100` |
405
405
  | `period` | Time window in seconds | `60` |
406
406
  | `namespace_id` | Unique ID (string) for the limiter | `"1001"` |
407
+
408
+ ## Encryption at rest
409
+
410
+ Sensitive credential fields (client secrets, connection secrets, email
411
+ credentials, TOTP secrets, migration-source secrets) are encrypted at rest.
412
+ A random `ENCRYPTION_KEY` was generated into `.dev.vars` when this project was
413
+ created, and `src/index.ts` enables encryption whenever the key is present.
414
+
415
+ > **The key is load-bearing.** If you delete, rotate, or lose `ENCRYPTION_KEY`,
416
+ > any values already encrypted with it become unreadable. In local dev you can
417
+ > recover by recreating the D1 database and re-seeding. In production, treat the
418
+ > key as a long-lived secret and back it up.
419
+
420
+ For production, set the key as a Worker secret (do **not** reuse the dev key):
421
+
422
+ ```bash
423
+ wrangler secret put ENCRYPTION_KEY
424
+ ```
425
+
426
+ Helper scripts:
427
+
428
+ ```bash
429
+ npm run gen:key # print a fresh base64 key
430
+ npm run decrypt -- "enc:v1:..." # decrypt a stored value using ENCRYPTION_KEY from .dev.vars
431
+ ```
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { loadEncryptionKey, decryptField } from "authhero";
3
+
4
+ // Decrypt a stored field value using ENCRYPTION_KEY from the environment.
5
+ // Usage: node --env-file=.env scripts/decrypt-field.mjs "enc:v1:..."
6
+ // Values without the enc:v1: prefix (legacy plaintext) are printed unchanged.
7
+ const value = process.argv[2];
8
+
9
+ if (!value) {
10
+ console.error(
11
+ 'Usage: node --env-file=<env> scripts/decrypt-field.mjs "<value>"',
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ const keyB64 = process.env.ENCRYPTION_KEY;
17
+ if (!keyB64) {
18
+ console.error(
19
+ "ENCRYPTION_KEY is not set. Pass it via --env-file or the environment.",
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ try {
25
+ const key = await loadEncryptionKey(keyB64);
26
+ console.log(await decryptField(value, key));
27
+ } catch (error) {
28
+ console.error(
29
+ "Failed to decrypt:",
30
+ error instanceof Error ? error.message : error,
31
+ );
32
+ process.exit(1);
33
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+
4
+ // Print a fresh base64-encoded 32-byte (AES-256) key suitable for
5
+ // ENCRYPTION_KEY. Copy the output into your env file (.env / .dev.vars) or set
6
+ // it as a production secret.
7
+ console.log(crypto.randomBytes(32).toString("base64"));
@@ -3,7 +3,11 @@ import { Kysely } from "kysely";
3
3
  import createAdapters from "@authhero/kysely-adapter";
4
4
  import createApp from "./app";
5
5
  import { Env } from "./types";
6
- import { AuthHeroConfig } from "authhero";
6
+ import {
7
+ AuthHeroConfig,
8
+ createEncryptedDataAdapter,
9
+ loadEncryptionKey,
10
+ } from "authhero";
7
11
 
8
12
  // ──────────────────────────────────────────────────────────────────────────────
9
13
  // OPTIONAL: Uncomment to enable Cloudflare adapters (Analytics Engine, etc.)
@@ -20,7 +24,15 @@ export default {
20
24
 
21
25
  const dialect = new D1Dialect({ database: env.AUTH_DB });
22
26
  const db = new Kysely<any>({ dialect });
23
- const dataAdapter = createAdapters(db, { useTransactions: false });
27
+ let dataAdapter = createAdapters(db, { useTransactions: false });
28
+
29
+ // Encrypt sensitive credential fields at rest when ENCRYPTION_KEY is set.
30
+ // In local dev it comes from .dev.vars; in production set it with
31
+ // `wrangler secret put ENCRYPTION_KEY`. Without it, behavior is unchanged.
32
+ if (env.ENCRYPTION_KEY) {
33
+ const encryptionKey = await loadEncryptionKey(env.ENCRYPTION_KEY);
34
+ dataAdapter = createEncryptedDataAdapter(dataAdapter, encryptionKey);
35
+ }
24
36
 
25
37
  // ────────────────────────────────────────────────────────────────────────
26
38
  // OPTIONAL: Cloudflare Analytics Engine for centralized logging
@@ -6,6 +6,11 @@
6
6
  export interface Env {
7
7
  AUTH_DB: D1Database;
8
8
 
9
+ // Base64-encoded 32-byte key for at-rest encryption of sensitive credential
10
+ // fields. Set in .dev.vars locally and via `wrangler secret put ENCRYPTION_KEY`
11
+ // in production. Optional — encryption is skipped when unset.
12
+ ENCRYPTION_KEY?: string;
13
+
9
14
  // ──────────────────────────────────────────────────────────────────────────
10
15
  // OPTIONAL: Analytics Engine for centralized logging
11
16
  // Uncomment to enable:
@@ -3,10 +3,14 @@ import { Command as e } from "commander";
3
3
  import t from "inquirer";
4
4
  import n from "fs";
5
5
  import r from "path";
6
- import { fileURLToPath as i } from "url";
7
- import { spawn as a } from "child_process";
6
+ import i from "crypto";
7
+ import { fileURLToPath as a } from "url";
8
+ import { spawn as o } from "child_process";
8
9
  //#region src/index.ts
9
- var o = new e(), s = {
10
+ function s() {
11
+ return i.randomBytes(32).toString("base64");
12
+ }
13
+ var c = new e(), l = {
10
14
  local: {
11
15
  name: "Local (SQLite)",
12
16
  description: "Local development setup with SQLite database - great for getting started",
@@ -18,10 +22,12 @@ var o = new e(), s = {
18
22
  version: "1.0.0",
19
23
  type: "module",
20
24
  scripts: {
21
- dev: "npx tsx watch src/index.ts",
22
- start: "npx tsx src/index.ts",
25
+ dev: "npx tsx watch --env-file=.env src/index.ts",
26
+ start: "npx tsx --env-file=.env src/index.ts",
23
27
  migrate: "npx tsx src/migrate.ts",
24
- seed: "npx tsx src/seed.ts"
28
+ seed: "npx tsx --env-file=.env src/seed.ts",
29
+ "gen:key": "node scripts/generate-encryption-key.mjs",
30
+ decrypt: "node --env-file=.env scripts/decrypt-field.mjs"
25
31
  },
26
32
  dependencies: {
27
33
  "@authhero/kysely-adapter": a,
@@ -69,7 +75,9 @@ var o = new e(), s = {
69
75
  "seed:local": "node seed-helper.js",
70
76
  "seed:remote": "node seed-helper.js '' '' remote",
71
77
  seed: "node seed-helper.js",
72
- setup: "cp wrangler.toml wrangler.local.toml && cp .dev.vars.example .dev.vars && echo '✅ Created wrangler.local.toml and .dev.vars - update with your IDs'"
78
+ setup: "cp wrangler.toml wrangler.local.toml && cp .dev.vars.example .dev.vars && echo '✅ Created wrangler.local.toml and .dev.vars - update with your IDs'",
79
+ "gen:key": "node scripts/generate-encryption-key.mjs",
80
+ decrypt: "node --env-file=.dev.vars scripts/decrypt-field.mjs"
73
81
  },
74
82
  dependencies: {
75
83
  "@authhero/drizzle": a,
@@ -135,8 +143,10 @@ var o = new e(), s = {
135
143
  dev: "sst dev",
136
144
  deploy: "sst deploy --stage production",
137
145
  remove: "sst remove",
138
- seed: "npx tsx src/seed.ts",
139
- "copy-assets": "node copy-assets.js"
146
+ seed: "npx tsx --env-file=.env src/seed.ts",
147
+ "copy-assets": "node copy-assets.js",
148
+ "gen:key": "node scripts/generate-encryption-key.mjs",
149
+ decrypt: "node --env-file=.env scripts/decrypt-field.mjs"
140
150
  },
141
151
  dependencies: {
142
152
  "@authhero/aws": a,
@@ -163,13 +173,13 @@ var o = new e(), s = {
163
173
  seedFile: "seed.ts"
164
174
  }
165
175
  };
166
- function c(e, t) {
176
+ function u(e, t) {
167
177
  n.readdirSync(e).forEach((i) => {
168
178
  let a = r.join(e, i), o = r.join(t, i);
169
- n.lstatSync(a).isDirectory() ? (n.mkdirSync(o, { recursive: !0 }), c(a, o)) : n.copyFileSync(a, o);
179
+ n.lstatSync(a).isDirectory() ? (n.mkdirSync(o, { recursive: !0 }), u(a, o)) : n.copyFileSync(a, o);
170
180
  });
171
181
  }
172
- function l(e, t = !1, n = "authhero-local", r) {
182
+ function d(e, t = !1, n = "authhero-local", r) {
173
183
  let i = e ? "control_plane" : "main", a = e ? "Control Plane" : "Main", o = [
174
184
  "https://manage.authhero.net/auth-callback",
175
185
  "https://local.authhero.net/auth-callback",
@@ -314,7 +324,7 @@ function l(e, t = !1, n = "authhero-local", r) {
314
324
  return `import { SqliteDialect, Kysely } from "kysely";
315
325
  import Database from "better-sqlite3";
316
326
  import createAdapters from "@authhero/kysely-adapter";
317
- import { seed${t ? ", USERNAME_PASSWORD_PROVIDER" : ""} } from "authhero";
327
+ import { seed, createEncryptedDataAdapter, loadEncryptionKey${t ? ", USERNAME_PASSWORD_PROVIDER" : ""} } from "authhero";
318
328
 
319
329
  interface ExtraClient {
320
330
  client_id: string;
@@ -368,7 +378,13 @@ async function main() {
368
378
  });
369
379
 
370
380
  const db = new Kysely<any>({ dialect });
371
- const adapters = createAdapters(db);
381
+ let adapters = createAdapters(db);
382
+
383
+ // Match the server: encrypt seeded secrets at rest when a key is configured.
384
+ if (process.env.ENCRYPTION_KEY) {
385
+ const encryptionKey = await loadEncryptionKey(process.env.ENCRYPTION_KEY);
386
+ adapters = createEncryptedDataAdapter(adapters, encryptionKey);
387
+ }
372
388
 
373
389
  const seedResult = await seed(adapters, {
374
390
  adminUsername,
@@ -417,7 +433,7 @@ main().catch((err) => {
417
433
  });
418
434
  `;
419
435
  }
420
- function u(e, t) {
436
+ function f(e, t) {
421
437
  let n = t ? "import fs from \"fs\";\n" : "", r = t ? "\nconst adminDistPath = path.resolve(\n __dirname,\n \"../node_modules/@authhero/admin/dist\",\n);\nconst adminIndexPath = path.join(adminDistPath, \"index.html\");\n" : "", i = t ? `
422
438
  // Add admin UI handler if the package is installed
423
439
  if (fs.existsSync(adminIndexPath)) {
@@ -546,14 +562,15 @@ ${i}
546
562
  }
547
563
  `;
548
564
  }
549
- function d(e) {
565
+ function p(e) {
550
566
  return `import { D1Dialect } from "kysely-d1";
551
567
  import { Kysely } from "kysely";
552
568
  import createAdapters from "@authhero/kysely-adapter";
553
- import { seed } from "authhero";
569
+ import { seed, createEncryptedDataAdapter, loadEncryptionKey } from "authhero";
554
570
 
555
571
  interface Env {
556
572
  AUTH_DB: D1Database;
573
+ ENCRYPTION_KEY?: string;
557
574
  }
558
575
 
559
576
  export default {
@@ -567,7 +584,12 @@ export default {
567
584
  try {
568
585
  const dialect = new D1Dialect({ database: env.AUTH_DB });
569
586
  const db = new Kysely<any>({ dialect });
570
- const adapters = createAdapters(db, { useTransactions: false });
587
+ let adapters = createAdapters(db, { useTransactions: false });
588
+
589
+ if (env.ENCRYPTION_KEY) {
590
+ const encryptionKey = await loadEncryptionKey(env.ENCRYPTION_KEY);
591
+ adapters = createEncryptedDataAdapter(adapters, encryptionKey);
592
+ }
571
593
 
572
594
  const result = await seed(adapters, {
573
595
  adminUsername,
@@ -607,7 +629,7 @@ export default {
607
629
  };
608
630
  `;
609
631
  }
610
- function f(e, t) {
632
+ function m(e, t) {
611
633
  let n = t ? "import adminIndexHtml from \"./admin-index-html\";\n" : "", r = t ? " adminIndexHtml,\n" : "";
612
634
  return e ? `import { Context } from "hono";
613
635
  import { swaggerUI } from "@hono/swagger-ui";
@@ -690,14 +712,14 @@ ${r} });
690
712
  }
691
713
  `;
692
714
  }
693
- function p(e) {
715
+ function h(e) {
694
716
  return e ? "import { Context } from \"hono\";\nimport { swaggerUI } from \"@hono/swagger-ui\";\nimport { AuthHeroConfig, DataAdapters } from \"authhero\";\nimport { initMultiTenant } from \"@authhero/multi-tenancy\";\n\n// Control plane configuration\nconst CONTROL_PLANE_TENANT_ID = \"control_plane\";\nconst CONTROL_PLANE_CLIENT_ID = \"default\";\n\ninterface AppConfig extends AuthHeroConfig {\n dataAdapter: DataAdapters;\n widgetUrl: string;\n}\n\nexport default function createApp(config: AppConfig) {\n // Initialize multi-tenant AuthHero\n const { app } = initMultiTenant({\n ...config,\n controlPlane: {\n tenantId: CONTROL_PLANE_TENANT_ID,\n clientId: CONTROL_PLANE_CLIENT_ID,\n },\n });\n\n app\n .onError((err, ctx) => {\n if (err && typeof err === \"object\" && \"getResponse\" in err) {\n return (err as { getResponse: () => Response }).getResponse();\n }\n console.error(err);\n return ctx.text(err instanceof Error ? err.message : \"Internal Server Error\", 500);\n })\n .get(\"/\", async (ctx: Context) => {\n return ctx.json({\n name: \"AuthHero Multi-Tenant Server (AWS)\",\n version: \"1.0.0\",\n status: \"running\",\n docs: \"/docs\",\n controlPlaneTenant: CONTROL_PLANE_TENANT_ID,\n });\n })\n .get(\"/docs\", swaggerUI({ url: \"/api/v2/spec\" }))\n // Redirect widget requests to S3/CloudFront\n .get(\"/u/widget/*\", async (ctx) => {\n const file = ctx.req.path.replace(\"/u/widget/\", \"\");\n return ctx.redirect(`${config.widgetUrl}/u/widget/${file}`);\n })\n .get(\"/u/*\", async (ctx) => {\n const file = ctx.req.path.replace(\"/u/\", \"\");\n return ctx.redirect(`${config.widgetUrl}/u/${file}`);\n });\n\n return app;\n}\n" : "import { Context } from \"hono\";\nimport { cors } from \"hono/cors\";\nimport { AuthHeroConfig, init, DataAdapters } from \"authhero\";\nimport { swaggerUI } from \"@hono/swagger-ui\";\n\ninterface AppConfig extends AuthHeroConfig {\n dataAdapter: DataAdapters;\n widgetUrl: string;\n}\n\nexport default function createApp(config: AppConfig) {\n const { app } = init(config);\n\n // Enable CORS for all origins in development\n app.use(\"*\", cors({\n origin: (origin) => origin || \"*\",\n allowMethods: [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"PATCH\", \"OPTIONS\"],\n allowHeaders: [\"Content-Type\", \"Authorization\", \"Auth0-Client\"],\n exposeHeaders: [\"Content-Length\"],\n credentials: true,\n }));\n\n app\n .onError((err, ctx) => {\n if (err && typeof err === \"object\" && \"getResponse\" in err) {\n return (err as { getResponse: () => Response }).getResponse();\n }\n console.error(err);\n return ctx.text(err instanceof Error ? err.message : \"Internal Server Error\", 500);\n })\n .get(\"/\", async (ctx: Context) => {\n return ctx.json({\n name: \"AuthHero Server (AWS)\",\n status: \"running\",\n });\n })\n .get(\"/docs\", swaggerUI({ url: \"/api/v2/spec\" }))\n // Redirect widget requests to S3/CloudFront\n .get(\"/u/widget/*\", async (ctx) => {\n const file = ctx.req.path.replace(\"/u/widget/\", \"\");\n return ctx.redirect(`${config.widgetUrl}/u/widget/${file}`);\n })\n .get(\"/u/*\", async (ctx) => {\n const file = ctx.req.path.replace(\"/u/\", \"\");\n return ctx.redirect(`${config.widgetUrl}/u/${file}`);\n });\n\n return app;\n}\n";
695
717
  }
696
- function m(e) {
718
+ function g(e) {
697
719
  return `import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
698
720
  import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
699
721
  import createAdapters from "@authhero/aws";
700
- import { seed } from "authhero";
722
+ import { seed, createEncryptedDataAdapter, loadEncryptionKey } from "authhero";
701
723
 
702
724
  async function main() {
703
725
  const adminUsername = process.argv[2] || process.env.ADMIN_USERNAME || "admin";
@@ -717,7 +739,12 @@ async function main() {
717
739
  },
718
740
  });
719
741
 
720
- const adapters = createAdapters(docClient, { tableName });
742
+ let adapters = createAdapters(docClient, { tableName });
743
+
744
+ if (process.env.ENCRYPTION_KEY) {
745
+ const encryptionKey = await loadEncryptionKey(process.env.ENCRYPTION_KEY);
746
+ adapters = createEncryptedDataAdapter(adapters, encryptionKey);
747
+ }
721
748
 
722
749
  await seed(adapters, {
723
750
  adminUsername,
@@ -733,18 +760,21 @@ async function main() {
733
760
  main().catch(console.error);
734
761
  `;
735
762
  }
736
- function h(e, t) {
763
+ function _(e, t) {
737
764
  let i = r.join(e, "src");
738
- n.writeFileSync(r.join(i, "app.ts"), p(t)), n.writeFileSync(r.join(i, "seed.ts"), m(t));
765
+ n.writeFileSync(r.join(i, "app.ts"), h(t)), n.writeFileSync(r.join(i, "seed.ts"), g(t)), n.writeFileSync(r.join(e, ".env"), `# At-rest encryption key for sensitive credentials. Generated automatically.
766
+ # Keep this stable and secret — losing it makes encrypted data unrecoverable.
767
+ ENCRYPTION_KEY=${s()}
768
+ `);
739
769
  }
740
- function g() {
770
+ function v() {
741
771
  console.log("\\n" + "─".repeat(50)), console.log("🔐 AuthHero deployed to AWS!"), console.log("📚 Check SST output for your API URL"), console.log("🚀 Open your server URL /setup to complete initial setup"), console.log("🌐 Portal available at https://local.authhero.net"), console.log("─".repeat(50) + "\\n");
742
772
  }
743
- function _(e) {
773
+ function y(e) {
744
774
  let t = r.join(e, ".github", "workflows");
745
775
  n.mkdirSync(t, { recursive: !0 }), n.writeFileSync(r.join(t, "unit-tests.yml"), "name: Unit tests\n\non: push\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n\n - name: Setup Node.js\n uses: actions/setup-node@v4\n with:\n node-version: \"22\"\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - run: npm run type-check\n - run: npm test\n"), n.writeFileSync(r.join(t, "deploy-dev.yml"), "name: Deploy to Dev\n\non:\n push:\n branches:\n - main\n\njobs:\n release:\n name: Release and Deploy\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - name: Setup Node.js\n uses: actions/setup-node@v4\n with:\n node-version: \"22\"\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Release\n env:\n GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n run: npx semantic-release\n\n - name: Deploy to Cloudflare (Dev)\n uses: cloudflare/wrangler-action@v3\n with:\n apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n command: deploy\n"), n.writeFileSync(r.join(t, "release.yml"), "name: Deploy to Production\n\non:\n release:\n types: [\"released\"]\n\njobs:\n deploy:\n name: Deploy to Production\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup Node.js\n uses: actions/setup-node@v4\n with:\n node-version: \"22\"\n cache: \"npm\"\n\n - name: Install dependencies\n run: npm ci\n\n - name: Deploy to Cloudflare (Production)\n uses: cloudflare/wrangler-action@v3\n with:\n apiToken: ${{ secrets.PROD_CLOUDFLARE_API_TOKEN }}\n command: deploy --env production\n"), console.log("\\n📦 GitHub CI workflows created!");
746
776
  }
747
- function v(e) {
777
+ function b(e) {
748
778
  n.writeFileSync(r.join(e, ".releaserc.json"), JSON.stringify({
749
779
  branches: ["main"],
750
780
  plugins: [
@@ -763,9 +793,9 @@ function v(e) {
763
793
  "type-check": "tsc --noEmit"
764
794
  }, n.writeFileSync(t, JSON.stringify(i, null, 2));
765
795
  }
766
- function y(e, t) {
796
+ function x(e, t) {
767
797
  return new Promise((n, r) => {
768
- let i = a(e, [], {
798
+ let i = o(e, [], {
769
799
  cwd: t,
770
800
  shell: !0,
771
801
  stdio: "inherit"
@@ -775,107 +805,113 @@ function y(e, t) {
775
805
  }), i.on("error", r);
776
806
  });
777
807
  }
778
- function b(e, t, i) {
808
+ function S(e, t, i) {
779
809
  let a = r.join(e, "src");
780
- n.writeFileSync(r.join(a, "app.ts"), f(t, i)), n.writeFileSync(r.join(a, "seed.ts"), d(t));
810
+ n.writeFileSync(r.join(a, "app.ts"), m(t, i)), n.writeFileSync(r.join(a, "seed.ts"), p(t));
781
811
  }
782
- function x() {
812
+ function C() {
783
813
  console.log("\n" + "─".repeat(50)), console.log("🔐 AuthHero server running at https://localhost:3000"), console.log("🚀 Open https://localhost:3000/setup to complete initial setup"), console.log("─".repeat(50) + "\n");
784
814
  }
785
- function S() {
815
+ function w() {
786
816
  console.log("\n" + "─".repeat(50)), console.log("🛰️ AuthHero proxy running at http://localhost:8787"), console.log("✏️ Edit src/proxy.config.ts to add hosts and routes"), console.log("📖 See README.md for deployment instructions"), console.log("─".repeat(50) + "\n");
787
817
  }
788
- function C() {
818
+ function T() {
789
819
  console.log("\n" + "─".repeat(50)), console.log("✅ Self-signed certificates generated with openssl"), console.log("⚠️ You may need to trust the certificate in your browser"), console.log("🔐 AuthHero server running at http://localhost:3000"), console.log("📚 API documentation available at http://localhost:3000/docs"), console.log("🚀 Open http://localhost:3000/setup to complete initial setup"), console.log("─".repeat(50) + "\n");
790
820
  }
791
- o.version("1.0.0").description("Create a new AuthHero project").argument("[project-name]", "name of the project").option("-t, --template <type>", "template type: local, cloudflare, aws-sst, or proxy").option("--package-manager <pm>", "package manager to use: npm, yarn, pnpm, or bun").option("--multi-tenant", "enable multi-tenant mode").option("--admin-ui", "include admin UI at /admin").option("--skip-install", "skip installing dependencies").option("--skip-migrate", "skip running database migrations").option("--skip-start", "skip starting the development server").option("--github-ci", "include GitHub CI workflows with semantic versioning").option("--conformance", "add OpenID conformance suite test clients").option("--conformance-alias <alias>", "alias for conformance suite (default: authhero-local)").option("--workspace", "use workspace:* dependencies for local monorepo development").option("-y, --yes", "skip all prompts and use defaults/provided options").action(async (e, a) => {
792
- let o = a.yes === !0;
821
+ c.version("1.0.0").description("Create a new AuthHero project").argument("[project-name]", "name of the project").option("-t, --template <type>", "template type: local, cloudflare, aws-sst, or proxy").option("--package-manager <pm>", "package manager to use: npm, yarn, pnpm, or bun").option("--multi-tenant", "enable multi-tenant mode").option("--admin-ui", "include admin UI at /admin").option("--skip-install", "skip installing dependencies").option("--skip-migrate", "skip running database migrations").option("--skip-start", "skip starting the development server").option("--github-ci", "include GitHub CI workflows with semantic versioning").option("--conformance", "add OpenID conformance suite test clients").option("--conformance-alias <alias>", "alias for conformance suite (default: authhero-local)").option("--workspace", "use workspace:* dependencies for local monorepo development").option("-y, --yes", "skip all prompts and use defaults/provided options").action(async (e, i) => {
822
+ let o = i.yes === !0;
793
823
  console.log("\n🔐 Welcome to AuthHero!\n");
794
- let d = e;
795
- d || (o ? (d = "auth-server", console.log(`Using default project name: ${d}`)) : d = (await t.prompt([{
824
+ let c = e;
825
+ c || (o ? (c = "auth-server", console.log(`Using default project name: ${c}`)) : c = (await t.prompt([{
796
826
  type: "input",
797
827
  name: "projectName",
798
828
  message: "Project name:",
799
829
  default: "auth-server",
800
830
  validate: (e) => e !== "" || "Project name cannot be empty"
801
831
  }])).projectName);
802
- let f = r.join(process.cwd(), d);
803
- n.existsSync(f) && (console.error(`❌ Project "${d}" already exists.`), process.exit(1));
804
- let p;
805
- a.template ? ([
832
+ let p = r.join(process.cwd(), c);
833
+ n.existsSync(p) && (console.error(`❌ Project "${c}" already exists.`), process.exit(1));
834
+ let m;
835
+ i.template ? ([
806
836
  "local",
807
837
  "cloudflare",
808
838
  "aws-sst",
809
839
  "proxy"
810
- ].includes(a.template) || (console.error(`❌ Invalid template: ${a.template}`), console.error("Valid options: local, cloudflare, aws-sst, proxy"), process.exit(1)), p = a.template, console.log(`Using template: ${s[p].name}`)) : p = (await t.prompt([{
840
+ ].includes(i.template) || (console.error(`❌ Invalid template: ${i.template}`), console.error("Valid options: local, cloudflare, aws-sst, proxy"), process.exit(1)), m = i.template, console.log(`Using template: ${l[m].name}`)) : m = (await t.prompt([{
811
841
  type: "list",
812
842
  name: "setupType",
813
843
  message: "Select your setup type:",
814
844
  choices: [
815
845
  {
816
- name: `${s.local.name}\n ${s.local.description}`,
846
+ name: `${l.local.name}\n ${l.local.description}`,
817
847
  value: "local",
818
- short: s.local.name
848
+ short: l.local.name
819
849
  },
820
850
  {
821
- name: `${s.cloudflare.name}\n ${s.cloudflare.description}`,
851
+ name: `${l.cloudflare.name}\n ${l.cloudflare.description}`,
822
852
  value: "cloudflare",
823
- short: s.cloudflare.name
853
+ short: l.cloudflare.name
824
854
  },
825
855
  {
826
- name: `${s["aws-sst"].name}\n ${s["aws-sst"].description}`,
856
+ name: `${l["aws-sst"].name}\n ${l["aws-sst"].description}`,
827
857
  value: "aws-sst",
828
- short: s["aws-sst"].name
858
+ short: l["aws-sst"].name
829
859
  },
830
860
  {
831
- name: `${s.proxy.name}\n ${s.proxy.description}`,
861
+ name: `${l.proxy.name}\n ${l.proxy.description}`,
832
862
  value: "proxy",
833
- short: s.proxy.name
863
+ short: l.proxy.name
834
864
  }
835
865
  ]
836
866
  }])).setupType;
837
- let m;
838
- m = p === "proxy" ? !1 : a.multiTenant === void 0 ? o ? !1 : (await t.prompt([{
867
+ let h;
868
+ h = m === "proxy" ? !1 : i.multiTenant === void 0 ? o ? !1 : (await t.prompt([{
839
869
  type: "confirm",
840
870
  name: "multiTenant",
841
871
  message: "Would you like to enable multi-tenant mode?",
842
872
  default: !1
843
- }])).multiTenant : a.multiTenant, m && console.log("Multi-tenant mode: enabled");
844
- let w = !1;
845
- (p === "local" || p === "cloudflare") && (w = a.adminUi === void 0 ? o ? !0 : (await t.prompt([{
873
+ }])).multiTenant : i.multiTenant, h && console.log("Multi-tenant mode: enabled");
874
+ let g = !1;
875
+ (m === "local" || m === "cloudflare") && (g = i.adminUi === void 0 ? o ? !0 : (await t.prompt([{
846
876
  type: "confirm",
847
877
  name: "adminUi",
848
878
  message: "Would you like to include the admin UI at /admin?",
849
879
  default: !0
850
- }])).adminUi : a.adminUi, w && console.log("Admin UI: enabled (available at /admin)"));
851
- let T = a.conformance || !1, E = a.conformanceAlias || "authhero-local";
852
- T && console.log(`OpenID Conformance Suite: enabled (alias: ${E})`);
853
- let D = a.workspace || !1;
854
- D && console.log("Workspace mode: enabled (using workspace:* dependencies)");
855
- let O = s[p];
856
- n.mkdirSync(f, { recursive: !0 }), n.writeFileSync(r.join(f, "package.json"), JSON.stringify(O.packageJson(d, m, T, D, w), null, 2));
857
- let k = O.templateDir, A = r.dirname(i(import.meta.url)), j = [r.join(A, k), r.join(A, "..", "templates", k)], M = j.find((e) => n.existsSync(e));
858
- if (M ? c(M, f) : (console.error(`❌ Template directory not found. Looked in:\n ${j.join("\n ")}`), process.exit(1)), p === "cloudflare" && b(f, m, w), p === "cloudflare") {
859
- let e = r.join(f, "wrangler.toml"), t = r.join(f, "wrangler.local.toml");
880
+ }])).adminUi : i.adminUi, g && console.log("Admin UI: enabled (available at /admin)"));
881
+ let E = i.conformance || !1, D = i.conformanceAlias || "authhero-local";
882
+ E && console.log(`OpenID Conformance Suite: enabled (alias: ${D})`);
883
+ let O = i.workspace || !1;
884
+ O && console.log("Workspace mode: enabled (using workspace:* dependencies)");
885
+ let k = l[m];
886
+ n.mkdirSync(p, { recursive: !0 }), n.writeFileSync(r.join(p, "package.json"), JSON.stringify(k.packageJson(c, h, E, O, g), null, 2));
887
+ let A = k.templateDir, j = r.dirname(a(import.meta.url)), M = [r.join(j, A), r.join(j, "..", "templates", A)], N = M.find((e) => n.existsSync(e));
888
+ if (N ? u(N, p) : (console.error(`❌ Template directory not found. Looked in:\n ${M.join("\n ")}`), process.exit(1)), m === "cloudflare" && S(p, h, g), m === "cloudflare") {
889
+ let e = r.join(p, "wrangler.toml"), t = r.join(p, "wrangler.local.toml");
860
890
  n.existsSync(e) && n.copyFileSync(e, t);
861
- let i = r.join(f, ".dev.vars.example"), a = r.join(f, ".dev.vars");
862
- n.existsSync(i) && n.copyFileSync(i, a), console.log("📁 Created wrangler.local.toml and .dev.vars for local development");
891
+ let i = r.join(p, ".dev.vars.example"), a = r.join(p, ".dev.vars");
892
+ n.existsSync(i) && (n.copyFileSync(i, a), n.appendFileSync(a, `\n# Generated at-rest encryption key (local dev). Use a separate secret in production.\nENCRYPTION_KEY=${s()}\n`), console.log("🔒 Added a generated ENCRYPTION_KEY to .dev.vars")), console.log("📁 Created wrangler.local.toml and .dev.vars for local development");
863
893
  }
864
- let N = !1;
865
- if (p === "cloudflare" && (a.githubCi === void 0 ? o || (N = (await t.prompt([{
894
+ let P = !1;
895
+ if (m === "cloudflare" && (i.githubCi === void 0 ? o || (P = (await t.prompt([{
866
896
  type: "confirm",
867
897
  name: "includeGithubCi",
868
898
  message: "Would you like to include GitHub CI with semantic versioning?",
869
899
  default: !1
870
- }])).includeGithubCi) : (N = a.githubCi, N && console.log("Including GitHub CI workflows with semantic versioning")), N && (_(f), v(f))), p === "local") {
871
- let e = l(m, T, E, w);
872
- n.writeFileSync(r.join(f, "src/seed.ts"), e);
873
- let t = u(m, w);
874
- n.writeFileSync(r.join(f, "src/app.ts"), t);
900
+ }])).includeGithubCi) : (P = i.githubCi, P && console.log("Including GitHub CI workflows with semantic versioning")), P && (y(p), b(p))), m === "local") {
901
+ let e = d(h, E, D, g);
902
+ n.writeFileSync(r.join(p, "src/seed.ts"), e);
903
+ let t = f(h, g);
904
+ n.writeFileSync(r.join(p, "src/app.ts"), t);
905
+ let i = `# Encryption key for at-rest encryption of sensitive credentials.
906
+ # Generated automatically. Keep this stable and secret — losing it makes
907
+ # existing encrypted data unrecoverable. Use a separate key in production.
908
+ ENCRYPTION_KEY=${s()}
909
+ `;
910
+ n.writeFileSync(r.join(p, ".env"), i), console.log("🔒 Generated .env with an at-rest encryption key");
875
911
  }
876
- if (p === "aws-sst" && h(f, m), T) {
912
+ if (m === "aws-sst" && _(p, h), E) {
877
913
  let e = {
878
- alias: E,
914
+ alias: D,
879
915
  description: "AuthHero Conformance Test",
880
916
  server: { discoveryUrl: "http://host.docker.internal:3000/.well-known/openid-configuration" },
881
917
  client: {
@@ -888,24 +924,24 @@ o.version("1.0.0").description("Create a new AuthHero project").argument("[proje
888
924
  },
889
925
  resource: { resourceUrl: "http://host.docker.internal:3000/userinfo" }
890
926
  };
891
- n.writeFileSync(r.join(f, "conformance-config.json"), JSON.stringify(e, null, 2)), console.log("📝 Created conformance-config.json for OpenID Conformance Suite");
927
+ n.writeFileSync(r.join(p, "conformance-config.json"), JSON.stringify(e, null, 2)), console.log("📝 Created conformance-config.json for OpenID Conformance Suite");
892
928
  }
893
- let P = m ? "multi-tenant" : "single-tenant";
894
- console.log(`\n✅ Project "${d}" has been created with ${O.name} (${P}) setup!\n`);
895
- let F;
896
- if (F = a.skipInstall ? !1 : o ? !0 : (await t.prompt([{
929
+ let F = h ? "multi-tenant" : "single-tenant";
930
+ console.log(`\n✅ Project "${c}" has been created with ${k.name} (${F}) setup!\n`);
931
+ let I;
932
+ if (I = i.skipInstall ? !1 : o ? !0 : (await t.prompt([{
897
933
  type: "confirm",
898
934
  name: "shouldInstall",
899
935
  message: "Would you like to install dependencies now?",
900
936
  default: !0
901
- }])).shouldInstall, F) {
937
+ }])).shouldInstall, I) {
902
938
  let e;
903
- a.packageManager ? ([
939
+ i.packageManager ? ([
904
940
  "npm",
905
941
  "yarn",
906
942
  "pnpm",
907
943
  "bun"
908
- ].includes(a.packageManager) || (console.error(`❌ Invalid package manager: ${a.packageManager}`), console.error("Valid options: npm, yarn, pnpm, bun"), process.exit(1)), e = a.packageManager) : e = o ? "pnpm" : (await t.prompt([{
944
+ ].includes(i.packageManager) || (console.error(`❌ Invalid package manager: ${i.packageManager}`), console.error("Valid options: npm, yarn, pnpm, bun"), process.exit(1)), e = i.packageManager) : e = o ? "pnpm" : (await t.prompt([{
909
945
  type: "list",
910
946
  name: "packageManager",
911
947
  message: "Which package manager would you like to use?",
@@ -930,26 +966,26 @@ o.version("1.0.0").description("Create a new AuthHero project").argument("[proje
930
966
  default: "pnpm"
931
967
  }])).packageManager, console.log(`\n📦 Installing dependencies with ${e}...\n`);
932
968
  try {
933
- if (await y(e === "pnpm" ? "pnpm install --ignore-workspace" : `${e} install`, f), p === "local" && (console.log("\n🔧 Building native modules...\n"), await y("npm rebuild better-sqlite3", f)), console.log("\n✅ Dependencies installed successfully!\n"), (p === "local" || p === "cloudflare") && !a.skipMigrate) {
969
+ if (await x(e === "pnpm" ? "pnpm install --ignore-workspace" : `${e} install`, p), m === "local" && (console.log("\n🔧 Building native modules...\n"), await x("npm rebuild better-sqlite3", p)), console.log("\n✅ Dependencies installed successfully!\n"), (m === "local" || m === "cloudflare") && !i.skipMigrate) {
934
970
  let n;
935
971
  n = o ? !0 : (await t.prompt([{
936
972
  type: "confirm",
937
973
  name: "shouldMigrate",
938
974
  message: "Would you like to run database migrations?",
939
975
  default: !0
940
- }])).shouldMigrate, n && (console.log("\n🔄 Running migrations...\n"), await y(`${e} run migrate`, f));
976
+ }])).shouldMigrate, n && (console.log("\n🔄 Running migrations...\n"), await x(`${e} run migrate`, p));
941
977
  }
942
978
  let n;
943
- n = a.skipStart || o ? !1 : (await t.prompt([{
979
+ n = i.skipStart || o ? !1 : (await t.prompt([{
944
980
  type: "confirm",
945
981
  name: "shouldStart",
946
982
  message: "Would you like to start the development server?",
947
983
  default: !0
948
- }])).shouldStart, n && (p === "cloudflare" ? x() : p === "aws-sst" ? g() : p === "proxy" ? S() : C(), console.log("🚀 Starting development server...\n"), await y(`${e} run dev`, f)), o && !n && (console.log("\n✅ Setup complete!"), console.log("\nTo start the development server:"), console.log(` cd ${d}`), console.log(" npm run dev"), p === "cloudflare" ? x() : p === "aws-sst" ? g() : p === "proxy" ? S() : C());
984
+ }])).shouldStart, n && (m === "cloudflare" ? C() : m === "aws-sst" ? v() : m === "proxy" ? w() : T(), console.log("🚀 Starting development server...\n"), await x(`${e} run dev`, p)), o && !n && (console.log("\n✅ Setup complete!"), console.log("\nTo start the development server:"), console.log(` cd ${c}`), console.log(" npm run dev"), m === "cloudflare" ? C() : m === "aws-sst" ? v() : m === "proxy" ? w() : T());
949
985
  } catch (e) {
950
986
  console.error("\n❌ An error occurred:", e), process.exit(1);
951
987
  }
952
988
  }
953
- F || (console.log("Next steps:"), console.log(` cd ${d}`), p === "local" ? (console.log(" npm install"), console.log(" npm run migrate"), console.log(" npm run dev"), console.log("\nOpen http://localhost:3000/setup to complete initial setup")) : p === "cloudflare" ? (console.log(" npm install"), console.log(" npm run migrate # or npm run db:migrate:remote for production"), console.log(" npm run dev # or npm run dev:remote for production"), console.log("\nOpen https://localhost:3000/setup to complete initial setup")) : p === "aws-sst" ? (console.log(" npm install"), console.log(" npm run dev # Deploys to AWS in development mode"), console.log("\nOpen your server URL /setup to complete initial setup")) : p === "proxy" && (console.log(" npm install"), console.log(" npm run dev"), console.log("\nEdit src/proxy.config.ts to add hosts and routes")), console.log(`\nServer will be available at: http://localhost:${p === "proxy" ? 8787 : 3e3}`), T && (console.log("\n🧪 OpenID Conformance Suite Testing:"), console.log(" 1. Clone and start the conformance suite (if not already running):"), console.log(" git clone https://gitlab.com/openid/conformance-suite.git"), console.log(" cd conformance-suite && mvn clean package"), console.log(" docker-compose up -d"), console.log(" 2. Open https://localhost.emobix.co.uk:8443"), console.log(" 3. Create a test plan and use conformance-config.json for settings"), console.log(` 4. Use alias: ${E}`)), console.log("\nFor more information, visit: https://authhero.net/docs\n"));
954
- }), o.parse(process.argv);
989
+ I || (console.log("Next steps:"), console.log(` cd ${c}`), m === "local" ? (console.log(" npm install"), console.log(" npm run migrate"), console.log(" npm run dev"), console.log("\nOpen http://localhost:3000/setup to complete initial setup")) : m === "cloudflare" ? (console.log(" npm install"), console.log(" npm run migrate # or npm run db:migrate:remote for production"), console.log(" npm run dev # or npm run dev:remote for production"), console.log("\nOpen https://localhost:3000/setup to complete initial setup")) : m === "aws-sst" ? (console.log(" npm install"), console.log(" npm run dev # Deploys to AWS in development mode"), console.log("\nOpen your server URL /setup to complete initial setup")) : m === "proxy" && (console.log(" npm install"), console.log(" npm run dev"), console.log("\nEdit src/proxy.config.ts to add hosts and routes")), console.log(`\nServer will be available at: http://localhost:${m === "proxy" ? 8787 : 3e3}`), E && (console.log("\n🧪 OpenID Conformance Suite Testing:"), console.log(" 1. Clone and start the conformance suite (if not already running):"), console.log(" git clone https://gitlab.com/openid/conformance-suite.git"), console.log(" cd conformance-suite && mvn clean package"), console.log(" docker-compose up -d"), console.log(" 2. Open https://localhost.emobix.co.uk:8443"), console.log(" 3. Create a test plan and use conformance-config.json for settings"), console.log(` 4. Use alias: ${D}`)), console.log("\nFor more information, visit: https://authhero.net/docs\n"));
990
+ }), c.parse(process.argv);
955
991
  //#endregion
@@ -47,4 +47,26 @@ You can customize the AuthHero configuration in `src/app.ts`. Common options inc
47
47
  - Custom email templates
48
48
  - Session configuration
49
49
 
50
+ ## Encryption at rest
51
+
52
+ Sensitive credential fields (client secrets, connection secrets, email
53
+ credentials, TOTP secrets, migration-source secrets) are encrypted at rest.
54
+ A random `ENCRYPTION_KEY` was generated into `.env` when this project was
55
+ created, and the dev/seed scripts load it via `--env-file=.env`.
56
+
57
+ > **The key is load-bearing.** If you delete, rotate, or lose `ENCRYPTION_KEY`,
58
+ > any values already encrypted with it become unreadable. In local dev you can
59
+ > recover by deleting `db.sqlite` and re-running `npm run migrate && npm run seed`.
60
+ > In production, treat the key as a long-lived secret and back it up.
61
+
62
+ For production, set your own `ENCRYPTION_KEY` in the deployment environment
63
+ rather than reusing the generated dev key.
64
+
65
+ Helper scripts:
66
+
67
+ ```bash
68
+ npm run gen:key # print a fresh base64 key
69
+ npm run decrypt -- "enc:v1:..." # decrypt a stored value using ENCRYPTION_KEY from .env
70
+ ```
71
+
50
72
  For more information, visit [https://authhero.net/docs](https://authhero.net/docs).
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { loadEncryptionKey, decryptField } from "authhero";
3
+
4
+ // Decrypt a stored field value using ENCRYPTION_KEY from the environment.
5
+ // Usage: node --env-file=.env scripts/decrypt-field.mjs "enc:v1:..."
6
+ // Values without the enc:v1: prefix (legacy plaintext) are printed unchanged.
7
+ const value = process.argv[2];
8
+
9
+ if (!value) {
10
+ console.error(
11
+ 'Usage: node --env-file=<env> scripts/decrypt-field.mjs "<value>"',
12
+ );
13
+ process.exit(1);
14
+ }
15
+
16
+ const keyB64 = process.env.ENCRYPTION_KEY;
17
+ if (!keyB64) {
18
+ console.error(
19
+ "ENCRYPTION_KEY is not set. Pass it via --env-file or the environment.",
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ try {
25
+ const key = await loadEncryptionKey(keyB64);
26
+ console.log(await decryptField(value, key));
27
+ } catch (error) {
28
+ console.error(
29
+ "Failed to decrypt:",
30
+ error instanceof Error ? error.message : error,
31
+ );
32
+ process.exit(1);
33
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+
4
+ // Print a fresh base64-encoded 32-byte (AES-256) key suitable for
5
+ // ENCRYPTION_KEY. Copy the output into your env file (.env / .dev.vars) or set
6
+ // it as a production secret.
7
+ console.log(crypto.randomBytes(32).toString("base64"));
@@ -3,6 +3,7 @@ import { SqliteDialect } from "kysely";
3
3
  import { Kysely } from "kysely";
4
4
  import Database from "better-sqlite3";
5
5
  import createAdapters from "@authhero/kysely-adapter";
6
+ import { createEncryptedDataAdapter, loadEncryptionKey } from "authhero";
6
7
  import createApp from "./app";
7
8
  import fs from "fs";
8
9
  import path from "path";
@@ -102,7 +103,14 @@ try {
102
103
  process.exit(1);
103
104
  }
104
105
 
105
- const dataAdapter = createAdapters(db);
106
+ let dataAdapter = createAdapters(db);
107
+
108
+ // Encrypt sensitive credential fields at rest when ENCRYPTION_KEY is set
109
+ // (generated into .env at scaffold time). Without it, behavior is unchanged.
110
+ if (process.env.ENCRYPTION_KEY) {
111
+ const encryptionKey = await loadEncryptionKey(process.env.ENCRYPTION_KEY);
112
+ dataAdapter = createEncryptedDataAdapter(dataAdapter, encryptionKey);
113
+ }
106
114
 
107
115
  // Create the AuthHero app
108
116
  const app = createApp({
@@ -2,7 +2,9 @@
2
2
 
3
3
  A Cloudflare Worker that proxies incoming requests to upstream services based on the request's `Host` header. Built on [@authhero/proxy](https://www.npmjs.com/package/@authhero/proxy).
4
4
 
5
- ## Configure your routes
5
+ This template ships with a static, in-file config so you can run it immediately. For production you'll typically swap that adapter for one that reads routes from your authhero deployment — see [Data sources](#data-sources) below.
6
+
7
+ ## Configure your routes (default)
6
8
 
7
9
  Edit [src/proxy.config.ts](src/proxy.config.ts) to map each public hostname to one or more upstream routes. Path patterns support `*` and `:param` segments, and routes are matched in priority order (lower wins).
8
10
 
@@ -23,6 +25,111 @@ export const proxyConfig: StaticProxyAdapterOptions = {
23
25
  };
24
26
  ```
25
27
 
28
+ ## Data sources
29
+
30
+ The proxy reads its routes through a `ProxyDataAdapter`. Three implementations are common:
31
+
32
+ | Adapter | Best for | Notes |
33
+ | --- | --- | --- |
34
+ | **Static** (default) | Local dev, small fixed deployments | Routes baked into the worker bundle; re-deploy to change them. |
35
+ | **Database** | Same-process or co-located deployments | Reads directly from the proxy_routes table that authhero writes to. |
36
+ | **HTTP / management API** | Geographically distributed proxies, hosted Workers | Calls `/api/v2/proxy-routes` on your authhero server with a service token. |
37
+
38
+ The authhero server exposes the management API (`/api/v2/proxy-routes`) and creates the underlying table automatically once the standard adapter migrations have run — see the `local` template's [src/index.ts](../local/src/index.ts).
39
+
40
+ ### Database-backed (Kysely)
41
+
42
+ Add the adapter and your Kysely driver, then swap the data line:
43
+
44
+ ```bash
45
+ npm install @authhero/kysely-adapter kysely
46
+ ```
47
+
48
+ ```ts
49
+ import { Kysely } from "kysely";
50
+ import { createProxyApp } from "@authhero/proxy";
51
+ import { createProxyDataAdapter } from "@authhero/kysely-adapter";
52
+
53
+ const db = new Kysely({ dialect: /* your dialect */ });
54
+ const app = createProxyApp({
55
+ data: createProxyDataAdapter(db),
56
+ });
57
+ ```
58
+
59
+ Cloudflare Workers can't open SQLite files, so on Workers this path means D1, Hyperdrive, or a remote MySQL/Postgres. For a local Node process, `better-sqlite3` pointing at the same `db.sqlite` your authhero server uses works out of the box.
60
+
61
+ ### HTTP-backed (management API)
62
+
63
+ The proxy authenticates to authhero's management API with a service token. Issue the token from authhero with the scopes `read:proxy_routes` and `read:custom_domains`, then store it as a worker secret:
64
+
65
+ ```bash
66
+ wrangler secret put AUTHHERO_SERVICE_TOKEN
67
+ ```
68
+
69
+ `wrangler.toml` vars:
70
+
71
+ ```toml
72
+ [vars]
73
+ AUTHHERO_API_URL = "https://auth.example.com"
74
+ AUTHHERO_TENANT_ID = "example"
75
+ ```
76
+
77
+ Then build an adapter that resolves the host via `/api/v2/custom-domains` and the routes via `/api/v2/proxy-routes`:
78
+
79
+ ```ts
80
+ import type { ProxyDataAdapter, ResolvedHost } from "@authhero/proxy";
81
+
82
+ interface Env {
83
+ AUTHHERO_API_URL: string;
84
+ AUTHHERO_TENANT_ID: string;
85
+ AUTHHERO_SERVICE_TOKEN: string;
86
+ }
87
+
88
+ function createHttpProxyAdapter(env: Env): ProxyDataAdapter {
89
+ const headers = {
90
+ "authorization": `Bearer ${env.AUTHHERO_SERVICE_TOKEN}`,
91
+ "tenant-id": env.AUTHHERO_TENANT_ID,
92
+ };
93
+
94
+ async function api<T>(path: string): Promise<T> {
95
+ const res = await fetch(`${env.AUTHHERO_API_URL}${path}`, { headers });
96
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
97
+ return res.json() as Promise<T>;
98
+ }
99
+
100
+ return {
101
+ // The proxy data plane only needs resolveHost; the CRUD methods on
102
+ // proxyRoutes stay unused (writes always go through authhero directly).
103
+ proxyRoutes: {
104
+ list: () => { throw new Error("read-only proxy adapter"); },
105
+ get: () => { throw new Error("read-only proxy adapter"); },
106
+ create: () => { throw new Error("read-only proxy adapter"); },
107
+ update: () => { throw new Error("read-only proxy adapter"); },
108
+ remove: () => { throw new Error("read-only proxy adapter"); },
109
+ },
110
+ async resolveHost(host): Promise<ResolvedHost | null> {
111
+ const domains = await api<{ custom_domains: Array<{
112
+ custom_domain_id: string; domain: string;
113
+ }> }>("/api/v2/custom-domains");
114
+ const match = domains.custom_domains.find((d) => d.domain === host);
115
+ if (!match) return null;
116
+
117
+ const routes = await api<{ proxy_routes: unknown[] }>(
118
+ `/api/v2/proxy-routes?custom_domain_id=${match.custom_domain_id}&per_page=200`,
119
+ );
120
+ return {
121
+ tenant_id: env.AUTHHERO_TENANT_ID,
122
+ custom_domain_id: match.custom_domain_id,
123
+ domain: host,
124
+ routes: routes.proxy_routes as ResolvedHost["routes"],
125
+ };
126
+ },
127
+ };
128
+ }
129
+ ```
130
+
131
+ Cache aggressively (see below) — every cold cache miss is two API round-trips.
132
+
26
133
  ## Caching
27
134
 
28
135
  Resolved hosts are cached in-memory per Worker isolate with a stale-while-revalidate strategy:
@@ -31,7 +138,7 @@ Resolved hosts are cached in-memory per Worker isolate with a stale-while-revali
31
138
  - **Stale** for the next hour — served from cache while a background refresh runs.
32
139
  - **Negative** (host not found) — cached for 30 seconds so newly-added hosts come online quickly.
33
140
 
34
- For the static adapter the "refresh" is just a re-read of the in-memory config, so the SWR window mainly matters when you swap to an HTTP- or database-backed adapter.
141
+ For the static adapter the "refresh" is just a re-read of the in-memory config, so the SWR window mainly matters when you swap to the HTTP- or database-backed adapter.
35
142
 
36
143
  ## Develop locally
37
144
 
@@ -2,7 +2,11 @@ import type { StaticProxyAdapterOptions } from "@authhero/proxy";
2
2
 
3
3
  // Map each public hostname to the routes the proxy should serve for it.
4
4
  // Routes are matched in priority order (lower priority wins). The path
5
- // pattern supports `*` and `:param` segments.
5
+ // pattern in `match.path` supports `*` and `:param` segments (Hono syntax).
6
+ //
7
+ // Each route is an ordered list of handlers. The last handler is the
8
+ // terminal — it produces the response (e.g. `http`, `redirect`, `static`,
9
+ // `service_binding`). Earlier handlers wrap it, like Hono middleware.
6
10
  //
7
11
  // Edit this file to add your hosts, then re-deploy.
8
12
  export const proxyConfig: StaticProxyAdapterOptions = {
@@ -11,10 +15,16 @@ export const proxyConfig: StaticProxyAdapterOptions = {
11
15
  tenant_id: "example",
12
16
  routes: [
13
17
  {
14
- path_pattern: "/*",
15
- upstream_type: "http",
16
- upstream_url: "https://upstream.example.com",
17
- preserve_host: false,
18
+ match: { path: "/*" },
19
+ handlers: [
20
+ {
21
+ type: "http",
22
+ options: {
23
+ upstream_url: "https://upstream.example.com",
24
+ preserve_host: false,
25
+ },
26
+ },
27
+ ],
18
28
  },
19
29
  ],
20
30
  },
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "type": "git",
6
6
  "url": "https://github.com/markusahlstrand/authhero"
7
7
  },
8
- "version": "0.43.0",
8
+ "version": "0.45.0",
9
9
  "type": "module",
10
10
  "main": "dist/create-authhero.js",
11
11
  "bin": {