create-authhero 0.44.0 → 0.46.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"));
@@ -1,9 +1,13 @@
1
- import { D1Dialect } from "kysely-d1";
2
- import { Kysely } from "kysely";
3
- import createAdapters from "@authhero/kysely-adapter";
1
+ import { drizzle } from "drizzle-orm/d1";
2
+ import createAdapters from "@authhero/drizzle";
3
+ import * as schema from "@authhero/drizzle/schema/sqlite";
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.)
@@ -18,9 +22,16 @@ export default {
18
22
  // Get the origin from the request for dynamic CORS
19
23
  const origin = request.headers.get("Origin") || "";
20
24
 
21
- const dialect = new D1Dialect({ database: env.AUTH_DB });
22
- const db = new Kysely<any>({ dialect });
23
- const dataAdapter = createAdapters(db, { useTransactions: false });
25
+ const db = drizzle(env.AUTH_DB, { schema });
26
+ let dataAdapter = createAdapters(db, { useTransactions: false });
27
+
28
+ // Encrypt sensitive credential fields at rest when ENCRYPTION_KEY is set.
29
+ // In local dev it comes from .dev.vars; in production set it with
30
+ // `wrangler secret put ENCRYPTION_KEY`. Without it, behavior is unchanged.
31
+ if (env.ENCRYPTION_KEY) {
32
+ const encryptionKey = await loadEncryptionKey(env.ENCRYPTION_KEY);
33
+ dataAdapter = createEncryptedDataAdapter(dataAdapter, encryptionKey);
34
+ }
24
35
 
25
36
  // ────────────────────────────────────────────────────────────────────────
26
37
  // 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:
@@ -13,7 +13,7 @@
13
13
 
14
14
  name = "authhero-server"
15
15
  main = "src/index.ts"
16
- compatibility_date = "2024-11-20"
16
+ compatibility_date = "2026-05-01"
17
17
  compatibility_flags = ["nodejs_compat"]
18
18
 
19
19
  # ════════════════════════════════════════════════════════════════════════════
@@ -0,0 +1,94 @@
1
+ # AuthHero WFP Dispatcher
2
+
3
+ Thin Cloudflare Worker that fronts a Workers-for-Platforms deployment of AuthHero. Resolves an incoming request's `Host` header to a tenant via the shared platform D1 (`custom_domains` table) and dispatches the request to that tenant's full authhero auth server, deployed as a script in a Cloudflare dispatch namespace.
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Internet
9
+ |
10
+ v
11
+ [This worker — the dispatcher]
12
+ 1. Host header -> custom_domains -> tenant_id
13
+ 2. env.DISPATCHER.get('tenant-<id>-auth').fetch(request)
14
+ |
15
+ v
16
+ [authhero-tenants dispatch namespace]
17
+ |- tenant-acme-auth (full authhero, deployed from the `cloudflare` template)
18
+ |- tenant-bob-auth
19
+ |- ...
20
+ ```
21
+
22
+ Tenant workers come from the **`cloudflare` create-authhero template** — they're the same single-tenant auth server, deployed into the namespace instead of standalone.
23
+
24
+ ## Prerequisites
25
+
26
+ - A Cloudflare account on the **Workers for Platforms** plan (required for dispatch namespaces).
27
+ - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/).
28
+ - A D1 database (shared with the tenant workers).
29
+
30
+ ## One-time platform setup
31
+
32
+ ```bash
33
+ # 1. Create the dispatch namespace
34
+ npx wrangler dispatch-namespace create authhero-tenants
35
+
36
+ # 2. Create the shared D1 (or reuse the one your tenant workers use)
37
+ npx wrangler d1 create authhero-db
38
+
39
+ # 3. Copy wrangler.toml -> wrangler.local.toml and paste the database_id
40
+
41
+ # 4. Install project dependencies (provides the local wrangler used by the
42
+ # db:migrate:* scripts)
43
+ npm install
44
+
45
+ # 5. Apply migrations to the shared D1
46
+ npm run db:migrate:remote
47
+ # Or, without installing dependencies first:
48
+ # npx wrangler d1 migrations apply AUTH_DB --remote --config wrangler.local.toml
49
+ ```
50
+
51
+ ## Deploy the dispatcher
52
+
53
+ ```bash
54
+ npm run deploy
55
+ ```
56
+
57
+ ## Onboard a tenant
58
+
59
+ For each publisher:
60
+
61
+ 1. **Provision the tenant in D1** — insert a `tenants` row, then a `custom_domains` row mapping their domain to the tenant_id. (Either via the authhero management API or by direct D1 query.)
62
+
63
+ 2. **Deploy their auth worker into the namespace.** From the sibling `cloudflare` template:
64
+
65
+ ```bash
66
+ # In the tenant's directory (scaffolded from `create-authhero --template=cloudflare`)
67
+ npx wrangler deploy \
68
+ --dispatch-namespace=authhero-tenants \
69
+ --name=tenant-<tenant_id>-auth
70
+ ```
71
+
72
+ 3. **Point their custom domain at this dispatcher worker** (Cloudflare → Workers → Triggers → Add Custom Domain on this worker, or via DNS to the worker's `*.workers.dev` route).
73
+
74
+ Once those three steps are done, a request to `auth.<their-domain>/authorize?...` flows to this dispatcher → resolved via custom_domains → dispatched to their tenant worker.
75
+
76
+ ## Per-tenant routing customization
77
+
78
+ By default, hosts with no `proxy_routes` rows get a single catch-all that dispatches to `tenant-<tenant_id>-auth`. If a tenant needs richer routing — different middleware chains, CORS, a special path that bypasses the namespace — insert `proxy_routes` rows for that `custom_domain_id`. The dispatcher uses the configured routes verbatim instead of the default.
79
+
80
+ The script-name template (`tenant-{tenant_id}-auth`) can be overridden globally via the `SCRIPT_NAME_TEMPLATE` env var. Supported placeholders: `{tenant_id}`, `{custom_domain_id}`, `{domain}`, `{host}`.
81
+
82
+ ## Local development
83
+
84
+ ```bash
85
+ npm run dev
86
+ ```
87
+
88
+ `wrangler dev` runs against a **local SQLite-backed D1**. The dispatch namespace cannot be emulated locally — for end-to-end tests, deploy to a real Cloudflare account using `npm run dev:remote`.
89
+
90
+ ## Files
91
+
92
+ - `src/index.ts` — dispatcher worker entrypoint
93
+ - `src/types.ts` — Env interface (D1 binding + DISPATCHER namespace binding)
94
+ - `wrangler.toml` — Cloudflare config (assets, D1, dispatch namespace)
@@ -0,0 +1,72 @@
1
+ import { drizzle } from "drizzle-orm/d1";
2
+ import { createProxyDataAdapter } from "@authhero/drizzle";
3
+ import * as schema from "@authhero/drizzle/schema/sqlite";
4
+ import {
5
+ createProxyApp,
6
+ type ProxyDataAdapter,
7
+ type ResolvedHost,
8
+ } from "@authhero/proxy";
9
+ import type { Env } from "./types";
10
+
11
+ // `tenant-{tenant_id}-auth` is the deploy convention used by this template's
12
+ // per-tenant worker setup. Override with the SCRIPT_NAME_TEMPLATE env var or
13
+ // by configuring proxy_routes rows per tenant for richer routing.
14
+ const DEFAULT_SCRIPT_NAME_TEMPLATE = "tenant-{tenant_id}-auth";
15
+
16
+ // If a host resolves to a known tenant but has no proxy_routes configured,
17
+ // synthesize a single catch-all that dispatches to the tenant's auth worker
18
+ // in the namespace. Operators who need middleware (CORS, headers, rate
19
+ // limiting) per tenant can add real proxy_routes rows and this fallback
20
+ // stays out of the way.
21
+ function withDefaultDispatchRoute(
22
+ inner: ProxyDataAdapter,
23
+ binding: string,
24
+ scriptNameTemplate: string,
25
+ ): ProxyDataAdapter {
26
+ return {
27
+ proxyRoutes: inner.proxyRoutes,
28
+ resolveHost: async (host): Promise<ResolvedHost | null> => {
29
+ const resolved = await inner.resolveHost(host);
30
+ if (!resolved || resolved.routes.length > 0) return resolved;
31
+ const now = new Date(0).toISOString();
32
+ return {
33
+ ...resolved,
34
+ routes: [
35
+ {
36
+ id: `default-${resolved.custom_domain_id}`,
37
+ tenant_id: resolved.tenant_id,
38
+ custom_domain_id: resolved.custom_domain_id,
39
+ priority: 1000,
40
+ match: { path: "/*" },
41
+ handlers: [
42
+ {
43
+ type: "dispatch_namespace",
44
+ options: { binding, script_name: scriptNameTemplate },
45
+ },
46
+ ],
47
+ created_at: now,
48
+ updated_at: now,
49
+ },
50
+ ],
51
+ };
52
+ },
53
+ };
54
+ }
55
+
56
+ export default {
57
+ async fetch(request: Request, env: Env): Promise<Response> {
58
+ const db = drizzle(env.AUTH_DB, { schema });
59
+ const data = withDefaultDispatchRoute(
60
+ createProxyDataAdapter(db),
61
+ "DISPATCHER",
62
+ env.SCRIPT_NAME_TEMPLATE || DEFAULT_SCRIPT_NAME_TEMPLATE,
63
+ );
64
+
65
+ const app = createProxyApp({
66
+ data,
67
+ bindings: { DISPATCHER: env.DISPATCHER },
68
+ });
69
+
70
+ return app.fetch(request);
71
+ },
72
+ };
@@ -0,0 +1,17 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ export interface Env {
4
+ // The shared platform D1 database. Holds custom_domains (host -> tenant)
5
+ // and proxy_routes (optional per-host route overrides).
6
+ AUTH_DB: D1Database;
7
+
8
+ // The Cloudflare Workers for Platforms dispatch namespace where each
9
+ // tenant's auth worker is deployed. Wrangler binding configured in
10
+ // wrangler.toml as `[[dispatch_namespaces]] binding = "DISPATCHER"`.
11
+ DISPATCHER: DispatchNamespace;
12
+
13
+ // Optional template for resolving a tenant to a dispatch namespace
14
+ // script name. Defaults to `tenant-{tenant_id}-auth`. Supported
15
+ // placeholders: {tenant_id}, {custom_domain_id}, {domain}, {host}.
16
+ SCRIPT_NAME_TEMPLATE?: string;
17
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "types": ["@cloudflare/workers-types"]
11
+ },
12
+ "include": ["src/**/*"],
13
+ "exclude": ["node_modules"]
14
+ }
@@ -0,0 +1,56 @@
1
+ # ════════════════════════════════════════════════════════════════════════════
2
+ # AuthHero WFP Dispatcher Configuration
3
+ # ════════════════════════════════════════════════════════════════════════════
4
+ # Thin Cloudflare Worker that resolves a request's Host header to a tenant
5
+ # (via the shared platform D1) and dispatches to that tenant's full
6
+ # authhero auth server, deployed as a script in the `authhero-tenants`
7
+ # dispatch namespace.
8
+ #
9
+ # Tenant workers are deployed separately via the `cloudflare` template,
10
+ # using `wrangler deploy --dispatch-namespace=authhero-tenants
11
+ # --name=tenant-<id>-auth`.
12
+ #
13
+ # Sensitive IDs (database_id) go in wrangler.local.toml (gitignored) or
14
+ # GitHub Secrets, not this file.
15
+ # ════════════════════════════════════════════════════════════════════════════
16
+
17
+ name = "authhero-wfp-dispatcher"
18
+ main = "src/index.ts"
19
+ compatibility_date = "2026-05-01"
20
+ compatibility_flags = ["nodejs_compat"]
21
+
22
+ # ════════════════════════════════════════════════════════════════════════════
23
+ # Dispatch namespace binding
24
+ # ════════════════════════════════════════════════════════════════════════════
25
+ # Create the namespace once before the first deploy:
26
+ # npx wrangler dispatch-namespace create authhero-tenants
27
+ [[dispatch_namespaces]]
28
+ binding = "DISPATCHER"
29
+ namespace = "authhero-tenants"
30
+
31
+ # ════════════════════════════════════════════════════════════════════════════
32
+ # Shared platform D1 database
33
+ # ════════════════════════════════════════════════════════════════════════════
34
+ # Same D1 that the tenant workers read/write. Holds custom_domains and
35
+ # proxy_routes that drive the dispatcher's host -> tenant resolution.
36
+ [[d1_databases]]
37
+ binding = "AUTH_DB"
38
+ database_name = "authhero-db"
39
+ database_id = "local" # Use "local" for local dev, or your actual ID in wrangler.local.toml
40
+ migrations_dir = "node_modules/@authhero/drizzle/drizzle"
41
+
42
+ # ════════════════════════════════════════════════════════════════════════════
43
+ # OPTIONAL: Custom Domain
44
+ # ════════════════════════════════════════════════════════════════════════════
45
+ # Point your platform's wildcard or per-publisher custom domains at this
46
+ # worker. The publishers' subdomains must resolve here so the dispatcher
47
+ # can route into the namespace.
48
+ #
49
+ # [[routes]]
50
+ # pattern = "*.auth.yourplatform.com/*"
51
+ # zone_name = "yourplatform.com"
52
+
53
+ # Optional: Enable observability for `wrangler tail` insight into dispatch
54
+ # decisions and namespace invocation latency.
55
+ # [observability]
56
+ # enabled = true
@@ -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,26 +75,25 @@ 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,
76
- "@authhero/kysely-adapter": a,
77
84
  ...i && { "@authhero/admin": a },
78
85
  "@authhero/widget": a,
79
86
  "@hono/swagger-ui": "^0.5.0",
80
87
  "@hono/zod-openapi": "^0.19.0",
81
88
  authhero: a,
89
+ "drizzle-orm": "^0.44.0",
82
90
  hono: "^4.6.0",
83
- kysely: "latest",
84
- "kysely-d1": "latest",
85
91
  ...t && { "@authhero/multi-tenancy": a },
86
92
  ...n && { bcryptjs: "latest" }
87
93
  },
88
94
  devDependencies: {
89
95
  "@cloudflare/workers-types": "^4.0.0",
90
96
  "drizzle-kit": "^0.31.0",
91
- "drizzle-orm": "^0.44.0",
92
97
  typescript: "^5.5.0",
93
98
  wrangler: "^3.0.0"
94
99
  }
@@ -96,6 +101,40 @@ var o = new e(), s = {
96
101
  },
97
102
  seedFile: "seed.ts"
98
103
  },
104
+ "cloudflare-wfp-dispatcher": {
105
+ name: "Cloudflare Workers for Platforms — Dispatcher",
106
+ description: "Thin dispatcher worker that routes per-publisher custom domains to tenant auth workers in a dispatch namespace (pair with the `cloudflare` template for tenant workers)",
107
+ templateDir: "cloudflare-wfp-dispatcher",
108
+ packageJson: (e, t, n, r) => {
109
+ let i = r ? "workspace:*" : "latest";
110
+ return {
111
+ name: e,
112
+ version: "1.0.0",
113
+ type: "module",
114
+ scripts: {
115
+ dev: "wrangler dev --port 3001 --local-protocol https",
116
+ "dev:remote": "wrangler dev --port 3001 --local-protocol https --remote --config wrangler.local.toml",
117
+ deploy: "wrangler deploy --config wrangler.local.toml",
118
+ "db:migrate:local": "wrangler d1 migrations apply AUTH_DB --local",
119
+ "db:migrate:remote": "wrangler d1 migrations apply AUTH_DB --remote --config wrangler.local.toml",
120
+ migrate: "wrangler d1 migrations apply AUTH_DB --local",
121
+ setup: "cp wrangler.toml wrangler.local.toml && echo '✅ Created wrangler.local.toml - update with your IDs'"
122
+ },
123
+ dependencies: {
124
+ "@authhero/drizzle": i,
125
+ "@authhero/proxy": i,
126
+ "drizzle-orm": "^0.44.0",
127
+ hono: "^4.6.0"
128
+ },
129
+ devDependencies: {
130
+ "@cloudflare/workers-types": "^4.0.0",
131
+ "drizzle-kit": "^0.31.0",
132
+ typescript: "^5.5.0",
133
+ wrangler: "^3.0.0"
134
+ }
135
+ };
136
+ }
137
+ },
99
138
  proxy: {
100
139
  name: "Proxy (Cloudflare Workers)",
101
140
  description: "Host-based reverse proxy on Cloudflare Workers — static config, no DB",
@@ -135,8 +174,10 @@ var o = new e(), s = {
135
174
  dev: "sst dev",
136
175
  deploy: "sst deploy --stage production",
137
176
  remove: "sst remove",
138
- seed: "npx tsx src/seed.ts",
139
- "copy-assets": "node copy-assets.js"
177
+ seed: "npx tsx --env-file=.env src/seed.ts",
178
+ "copy-assets": "node copy-assets.js",
179
+ "gen:key": "node scripts/generate-encryption-key.mjs",
180
+ decrypt: "node --env-file=.env scripts/decrypt-field.mjs"
140
181
  },
141
182
  dependencies: {
142
183
  "@authhero/aws": a,
@@ -163,13 +204,13 @@ var o = new e(), s = {
163
204
  seedFile: "seed.ts"
164
205
  }
165
206
  };
166
- function c(e, t) {
207
+ function u(e, t) {
167
208
  n.readdirSync(e).forEach((i) => {
168
209
  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);
210
+ n.lstatSync(a).isDirectory() ? (n.mkdirSync(o, { recursive: !0 }), u(a, o)) : n.copyFileSync(a, o);
170
211
  });
171
212
  }
172
- function l(e, t = !1, n = "authhero-local", r) {
213
+ function d(e, t = !1, n = "authhero-local", r) {
173
214
  let i = e ? "control_plane" : "main", a = e ? "Control Plane" : "Main", o = [
174
215
  "https://manage.authhero.net/auth-callback",
175
216
  "https://local.authhero.net/auth-callback",
@@ -314,7 +355,7 @@ function l(e, t = !1, n = "authhero-local", r) {
314
355
  return `import { SqliteDialect, Kysely } from "kysely";
315
356
  import Database from "better-sqlite3";
316
357
  import createAdapters from "@authhero/kysely-adapter";
317
- import { seed${t ? ", USERNAME_PASSWORD_PROVIDER" : ""} } from "authhero";
358
+ import { seed, createEncryptedDataAdapter, loadEncryptionKey${t ? ", USERNAME_PASSWORD_PROVIDER" : ""} } from "authhero";
318
359
 
319
360
  interface ExtraClient {
320
361
  client_id: string;
@@ -368,7 +409,13 @@ async function main() {
368
409
  });
369
410
 
370
411
  const db = new Kysely<any>({ dialect });
371
- const adapters = createAdapters(db);
412
+ let adapters = createAdapters(db);
413
+
414
+ // Match the server: encrypt seeded secrets at rest when a key is configured.
415
+ if (process.env.ENCRYPTION_KEY) {
416
+ const encryptionKey = await loadEncryptionKey(process.env.ENCRYPTION_KEY);
417
+ adapters = createEncryptedDataAdapter(adapters, encryptionKey);
418
+ }
372
419
 
373
420
  const seedResult = await seed(adapters, {
374
421
  adminUsername,
@@ -417,7 +464,7 @@ main().catch((err) => {
417
464
  });
418
465
  `;
419
466
  }
420
- function u(e, t) {
467
+ function f(e, t) {
421
468
  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
469
  // Add admin UI handler if the package is installed
423
470
  if (fs.existsSync(adminIndexPath)) {
@@ -546,14 +593,15 @@ ${i}
546
593
  }
547
594
  `;
548
595
  }
549
- function d(e) {
550
- return `import { D1Dialect } from "kysely-d1";
551
- import { Kysely } from "kysely";
552
- import createAdapters from "@authhero/kysely-adapter";
553
- import { seed } from "authhero";
596
+ function p(e) {
597
+ return `import { drizzle } from "drizzle-orm/d1";
598
+ import createAdapters from "@authhero/drizzle";
599
+ import * as schema from "@authhero/drizzle/schema/sqlite";
600
+ import { seed, createEncryptedDataAdapter, loadEncryptionKey } from "authhero";
554
601
 
555
602
  interface Env {
556
603
  AUTH_DB: D1Database;
604
+ ENCRYPTION_KEY?: string;
557
605
  }
558
606
 
559
607
  export default {
@@ -565,9 +613,13 @@ export default {
565
613
  const issuer = \`\${url.protocol}//\${url.host}/\`;
566
614
 
567
615
  try {
568
- const dialect = new D1Dialect({ database: env.AUTH_DB });
569
- const db = new Kysely<any>({ dialect });
570
- const adapters = createAdapters(db, { useTransactions: false });
616
+ const db = drizzle(env.AUTH_DB, { schema });
617
+ let adapters = createAdapters(db, { useTransactions: false });
618
+
619
+ if (env.ENCRYPTION_KEY) {
620
+ const encryptionKey = await loadEncryptionKey(env.ENCRYPTION_KEY);
621
+ adapters = createEncryptedDataAdapter(adapters, encryptionKey);
622
+ }
571
623
 
572
624
  const result = await seed(adapters, {
573
625
  adminUsername,
@@ -607,7 +659,7 @@ export default {
607
659
  };
608
660
  `;
609
661
  }
610
- function f(e, t) {
662
+ function m(e, t) {
611
663
  let n = t ? "import adminIndexHtml from \"./admin-index-html\";\n" : "", r = t ? " adminIndexHtml,\n" : "";
612
664
  return e ? `import { Context } from "hono";
613
665
  import { swaggerUI } from "@hono/swagger-ui";
@@ -690,14 +742,14 @@ ${r} });
690
742
  }
691
743
  `;
692
744
  }
693
- function p(e) {
745
+ function h(e) {
694
746
  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
747
  }
696
- function m(e) {
748
+ function g(e) {
697
749
  return `import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
698
750
  import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
699
751
  import createAdapters from "@authhero/aws";
700
- import { seed } from "authhero";
752
+ import { seed, createEncryptedDataAdapter, loadEncryptionKey } from "authhero";
701
753
 
702
754
  async function main() {
703
755
  const adminUsername = process.argv[2] || process.env.ADMIN_USERNAME || "admin";
@@ -717,7 +769,12 @@ async function main() {
717
769
  },
718
770
  });
719
771
 
720
- const adapters = createAdapters(docClient, { tableName });
772
+ let adapters = createAdapters(docClient, { tableName });
773
+
774
+ if (process.env.ENCRYPTION_KEY) {
775
+ const encryptionKey = await loadEncryptionKey(process.env.ENCRYPTION_KEY);
776
+ adapters = createEncryptedDataAdapter(adapters, encryptionKey);
777
+ }
721
778
 
722
779
  await seed(adapters, {
723
780
  adminUsername,
@@ -733,18 +790,21 @@ async function main() {
733
790
  main().catch(console.error);
734
791
  `;
735
792
  }
736
- function h(e, t) {
793
+ function _(e, t) {
737
794
  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));
795
+ 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.
796
+ # Keep this stable and secret — losing it makes encrypted data unrecoverable.
797
+ ENCRYPTION_KEY=${s()}
798
+ `);
739
799
  }
740
- function g() {
800
+ function v() {
741
801
  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
802
  }
743
- function _(e) {
803
+ function y(e) {
744
804
  let t = r.join(e, ".github", "workflows");
745
805
  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
806
  }
747
- function v(e) {
807
+ function b(e) {
748
808
  n.writeFileSync(r.join(e, ".releaserc.json"), JSON.stringify({
749
809
  branches: ["main"],
750
810
  plugins: [
@@ -763,9 +823,9 @@ function v(e) {
763
823
  "type-check": "tsc --noEmit"
764
824
  }, n.writeFileSync(t, JSON.stringify(i, null, 2));
765
825
  }
766
- function y(e, t) {
826
+ function x(e, t) {
767
827
  return new Promise((n, r) => {
768
- let i = a(e, [], {
828
+ let i = o(e, [], {
769
829
  cwd: t,
770
830
  shell: !0,
771
831
  stdio: "inherit"
@@ -775,107 +835,123 @@ function y(e, t) {
775
835
  }), i.on("error", r);
776
836
  });
777
837
  }
778
- function b(e, t, i) {
838
+ function S(e, t, i) {
779
839
  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));
840
+ n.writeFileSync(r.join(a, "app.ts"), m(t, i)), n.writeFileSync(r.join(a, "seed.ts"), p(t));
781
841
  }
782
- function x() {
842
+ function C() {
783
843
  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
844
  }
785
- function S() {
845
+ function w() {
846
+ console.log("\n" + "─".repeat(50)), console.log("🛰️ WFP dispatcher running at https://localhost:3001"), console.log("📦 Pair with the `cloudflare` template to deploy tenant workers:"), console.log(" wrangler deploy --dispatch-namespace=authhero-tenants --name=tenant-<id>-auth"), console.log("📖 See README.md for the full onboarding workflow"), console.log("─".repeat(50) + "\n");
847
+ }
848
+ function T() {
786
849
  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
850
  }
788
- function C() {
851
+ function E() {
789
852
  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
853
  }
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;
854
+ 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) => {
855
+ let o = i.yes === !0;
793
856
  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([{
857
+ let c = e;
858
+ c || (o ? (c = "auth-server", console.log(`Using default project name: ${c}`)) : c = (await t.prompt([{
796
859
  type: "input",
797
860
  name: "projectName",
798
861
  message: "Project name:",
799
862
  default: "auth-server",
800
863
  validate: (e) => e !== "" || "Project name cannot be empty"
801
864
  }])).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 ? ([
865
+ let p = r.join(process.cwd(), c);
866
+ n.existsSync(p) && (console.error(`❌ Project "${c}" already exists.`), process.exit(1));
867
+ let m;
868
+ i.template ? ([
806
869
  "local",
807
870
  "cloudflare",
871
+ "cloudflare-wfp-dispatcher",
808
872
  "aws-sst",
809
873
  "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([{
874
+ ].includes(i.template) || (console.error(`❌ Invalid template: ${i.template}`), console.error("Valid options: local, cloudflare, cloudflare-wfp-dispatcher, aws-sst, proxy"), process.exit(1)), m = i.template, console.log(`Using template: ${l[m].name}`)) : m = (await t.prompt([{
811
875
  type: "list",
812
876
  name: "setupType",
813
877
  message: "Select your setup type:",
814
878
  choices: [
815
879
  {
816
- name: `${s.local.name}\n ${s.local.description}`,
880
+ name: `${l.local.name}\n ${l.local.description}`,
817
881
  value: "local",
818
- short: s.local.name
882
+ short: l.local.name
819
883
  },
820
884
  {
821
- name: `${s.cloudflare.name}\n ${s.cloudflare.description}`,
885
+ name: `${l.cloudflare.name}\n ${l.cloudflare.description}`,
822
886
  value: "cloudflare",
823
- short: s.cloudflare.name
887
+ short: l.cloudflare.name
888
+ },
889
+ {
890
+ name: `${l["cloudflare-wfp-dispatcher"].name}\n ${l["cloudflare-wfp-dispatcher"].description}`,
891
+ value: "cloudflare-wfp-dispatcher",
892
+ short: l["cloudflare-wfp-dispatcher"].name
824
893
  },
825
894
  {
826
- name: `${s["aws-sst"].name}\n ${s["aws-sst"].description}`,
895
+ name: `${l["aws-sst"].name}\n ${l["aws-sst"].description}`,
827
896
  value: "aws-sst",
828
- short: s["aws-sst"].name
897
+ short: l["aws-sst"].name
829
898
  },
830
899
  {
831
- name: `${s.proxy.name}\n ${s.proxy.description}`,
900
+ name: `${l.proxy.name}\n ${l.proxy.description}`,
832
901
  value: "proxy",
833
- short: s.proxy.name
902
+ short: l.proxy.name
834
903
  }
835
904
  ]
836
905
  }])).setupType;
837
- let m;
838
- m = p === "proxy" ? !1 : a.multiTenant === void 0 ? o ? !1 : (await t.prompt([{
906
+ let h;
907
+ h = m === "proxy" || m === "cloudflare-wfp-dispatcher" ? !1 : i.multiTenant === void 0 ? o ? !1 : (await t.prompt([{
839
908
  type: "confirm",
840
909
  name: "multiTenant",
841
910
  message: "Would you like to enable multi-tenant mode?",
842
911
  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([{
912
+ }])).multiTenant : i.multiTenant, h && console.log("Multi-tenant mode: enabled");
913
+ let g = !1;
914
+ (m === "local" || m === "cloudflare") && (g = i.adminUi === void 0 ? o ? !0 : (await t.prompt([{
846
915
  type: "confirm",
847
916
  name: "adminUi",
848
917
  message: "Would you like to include the admin UI at /admin?",
849
918
  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");
860
- 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");
919
+ }])).adminUi : i.adminUi, g && console.log("Admin UI: enabled (available at /admin)"));
920
+ let D = i.conformance || !1, O = i.conformanceAlias || "authhero-local";
921
+ D && console.log(`OpenID Conformance Suite: enabled (alias: ${O})`);
922
+ let k = i.workspace || !1;
923
+ k && console.log("Workspace mode: enabled (using workspace:* dependencies)");
924
+ let A = l[m];
925
+ n.mkdirSync(p, { recursive: !0 }), n.writeFileSync(r.join(p, "package.json"), JSON.stringify(A.packageJson(c, h, D, k, g), null, 2));
926
+ let j = A.templateDir, M = r.dirname(a(import.meta.url)), N = [r.join(M, j), r.join(M, "..", "templates", j)], P = N.find((e) => n.existsSync(e));
927
+ if (P ? u(P, p) : (console.error(`❌ Template directory not found. Looked in:\n ${N.join("\n ")}`), process.exit(1)), m === "cloudflare" && S(p, h, g), m === "cloudflare" || m === "cloudflare-wfp-dispatcher") {
928
+ let e = r.join(p, "wrangler.toml"), t = r.join(p, "wrangler.local.toml");
929
+ if (n.existsSync(e) && !n.existsSync(t) && n.copyFileSync(e, t), m === "cloudflare") {
930
+ let e = r.join(p, ".dev.vars.example"), t = r.join(p, ".dev.vars");
931
+ n.existsSync(e) && (n.copyFileSync(e, t), n.appendFileSync(t, `\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");
932
+ } else console.log("📁 Created wrangler.local.toml for local development");
863
933
  }
864
- let N = !1;
865
- if (p === "cloudflare" && (a.githubCi === void 0 ? o || (N = (await t.prompt([{
934
+ let F = !1;
935
+ if (m === "cloudflare" && (i.githubCi === void 0 ? o || (F = (await t.prompt([{
866
936
  type: "confirm",
867
937
  name: "includeGithubCi",
868
938
  message: "Would you like to include GitHub CI with semantic versioning?",
869
939
  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);
940
+ }])).includeGithubCi) : (F = i.githubCi, F && console.log("Including GitHub CI workflows with semantic versioning")), F && (y(p), b(p))), m === "local") {
941
+ let e = d(h, D, O, g);
942
+ n.writeFileSync(r.join(p, "src/seed.ts"), e);
943
+ let t = f(h, g);
944
+ n.writeFileSync(r.join(p, "src/app.ts"), t);
945
+ let i = `# Encryption key for at-rest encryption of sensitive credentials.
946
+ # Generated automatically. Keep this stable and secret — losing it makes
947
+ # existing encrypted data unrecoverable. Use a separate key in production.
948
+ ENCRYPTION_KEY=${s()}
949
+ `;
950
+ n.writeFileSync(r.join(p, ".env"), i), console.log("🔒 Generated .env with an at-rest encryption key");
875
951
  }
876
- if (p === "aws-sst" && h(f, m), T) {
952
+ if (m === "aws-sst" && _(p, h), D) {
877
953
  let e = {
878
- alias: E,
954
+ alias: O,
879
955
  description: "AuthHero Conformance Test",
880
956
  server: { discoveryUrl: "http://host.docker.internal:3000/.well-known/openid-configuration" },
881
957
  client: {
@@ -888,24 +964,24 @@ o.version("1.0.0").description("Create a new AuthHero project").argument("[proje
888
964
  },
889
965
  resource: { resourceUrl: "http://host.docker.internal:3000/userinfo" }
890
966
  };
891
- n.writeFileSync(r.join(f, "conformance-config.json"), JSON.stringify(e, null, 2)), console.log("📝 Created conformance-config.json for OpenID Conformance Suite");
967
+ n.writeFileSync(r.join(p, "conformance-config.json"), JSON.stringify(e, null, 2)), console.log("📝 Created conformance-config.json for OpenID Conformance Suite");
892
968
  }
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([{
969
+ let I = h ? "multi-tenant" : "single-tenant";
970
+ console.log(`\n✅ Project "${c}" has been created with ${A.name} (${I}) setup!\n`);
971
+ let L;
972
+ if (L = i.skipInstall ? !1 : o ? !0 : (await t.prompt([{
897
973
  type: "confirm",
898
974
  name: "shouldInstall",
899
975
  message: "Would you like to install dependencies now?",
900
976
  default: !0
901
- }])).shouldInstall, F) {
977
+ }])).shouldInstall, L) {
902
978
  let e;
903
- a.packageManager ? ([
979
+ i.packageManager ? ([
904
980
  "npm",
905
981
  "yarn",
906
982
  "pnpm",
907
983
  "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([{
984
+ ].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
985
  type: "list",
910
986
  name: "packageManager",
911
987
  message: "Which package manager would you like to use?",
@@ -930,26 +1006,26 @@ o.version("1.0.0").description("Create a new AuthHero project").argument("[proje
930
1006
  default: "pnpm"
931
1007
  }])).packageManager, console.log(`\n📦 Installing dependencies with ${e}...\n`);
932
1008
  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) {
1009
+ 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
1010
  let n;
935
1011
  n = o ? !0 : (await t.prompt([{
936
1012
  type: "confirm",
937
1013
  name: "shouldMigrate",
938
1014
  message: "Would you like to run database migrations?",
939
1015
  default: !0
940
- }])).shouldMigrate, n && (console.log("\n🔄 Running migrations...\n"), await y(`${e} run migrate`, f));
1016
+ }])).shouldMigrate, n && (console.log("\n🔄 Running migrations...\n"), await x(`${e} run migrate`, p));
941
1017
  }
942
1018
  let n;
943
- n = a.skipStart || o ? !1 : (await t.prompt([{
1019
+ n = i.skipStart || o ? !1 : (await t.prompt([{
944
1020
  type: "confirm",
945
1021
  name: "shouldStart",
946
1022
  message: "Would you like to start the development server?",
947
1023
  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());
1024
+ }])).shouldStart, n && (m === "cloudflare" ? C() : m === "cloudflare-wfp-dispatcher" ? w() : m === "aws-sst" ? v() : m === "proxy" ? T() : E(), 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 === "cloudflare-wfp-dispatcher" ? w() : m === "aws-sst" ? v() : m === "proxy" ? T() : E());
949
1025
  } catch (e) {
950
1026
  console.error("\n❌ An error occurred:", e), process.exit(1);
951
1027
  }
952
1028
  }
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);
1029
+ L || (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 === "cloudflare-wfp-dispatcher" ? (console.log(" npm install"), console.log(" npm run setup # creates wrangler.local.toml — paste your database_id"), console.log(" npx wrangler dispatch-namespace create authhero-tenants"), console.log(" npm run dev # or npm run dev:remote for production"), console.log("\nDeploy tenant workers separately (`cloudflare` template):"), console.log(" wrangler deploy --dispatch-namespace=authhero-tenants --name=tenant-<id>-auth")) : 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 : m === "cloudflare-wfp-dispatcher" ? 3001 : 3e3}`), D && (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: ${O}`)), console.log("\nFor more information, visit: https://authhero.net/docs\n"));
1030
+ }), c.parse(process.argv);
955
1031
  //#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({
@@ -1,8 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import {
3
- createProxyApp,
4
- createStaticProxyAdapter,
5
- } from "@authhero/proxy";
2
+ import { createProxyApp, createStaticProxyAdapter } from "@authhero/proxy";
6
3
  import { proxyConfig } from "./proxy.config";
7
4
 
8
5
  // AsyncLocalStorage threads each request's ExecutionContext through to the
@@ -32,9 +29,8 @@ const app = createProxyApp({
32
29
 
33
30
  export default {
34
31
  fetch(request: Request, _env: unknown, ctx: ExecutionContext) {
35
- return requestCtx.run(
36
- { waitUntil: ctx.waitUntil.bind(ctx) },
37
- () => app.fetch(request),
32
+ return requestCtx.run({ waitUntil: ctx.waitUntil.bind(ctx) }, () =>
33
+ app.fetch(request),
38
34
  );
39
35
  },
40
36
  };
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.44.0",
8
+ "version": "0.46.0",
9
9
  "type": "module",
10
10
  "main": "dist/create-authhero.js",
11
11
  "bin": {