@o3co/create-auth-provider 0.9.0 → 0.11.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.
Files changed (37) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +110 -7
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +49 -5
  6. package/templates/standalone/Dockerfile +59 -16
  7. package/templates/standalone/README.ja.md +92 -13
  8. package/templates/standalone/README.md +358 -18
  9. package/templates/standalone/config/application.conf +132 -4
  10. package/templates/standalone/config/clients.yaml.example +56 -2
  11. package/templates/standalone/config/production.conf +9 -3
  12. package/templates/standalone/docker-compose.production.yml +132 -0
  13. package/templates/standalone/docker-compose.yml +7 -0
  14. package/templates/standalone/gitignore +21 -0
  15. package/templates/standalone/package.json +15 -20
  16. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
  18. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  19. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  20. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  21. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
  22. package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
  23. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  24. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  25. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  26. package/templates/standalone/src/app.mts +78 -10
  27. package/templates/standalone/src/buildModules.mts +43 -1
  28. package/templates/standalone/src/configPath.mts +2 -2
  29. package/templates/standalone/src/logger.mts +90 -2
  30. package/templates/standalone/src/metrics.mts +190 -0
  31. package/templates/standalone/src/modules.mts +319 -33
  32. package/templates/standalone/src/shutdown.mts +162 -0
  33. package/templates/standalone/src/terminalError.mts +60 -0
  34. package/templates/standalone/tests/index.test.js +69 -5
  35. package/templates/standalone/tsconfig.json +9 -1
  36. package/templates/standalone/vitest.config.mts +8 -0
  37. package/templates/versions.json +12 -11
package/dist/index.d.mts CHANGED
@@ -1,5 +1,25 @@
1
1
  export declare const isValidProjectName: (name: string) => boolean;
2
2
  export declare const isValidDirName: (name: string) => boolean;
3
3
  export declare const scaffold: (targetDir: string, projectName: string) => void;
4
+ /** Outcome of the scaffold-time lockfile generation. */
5
+ export type LockfileResult = {
6
+ readonly ok: true;
7
+ readonly command: string;
8
+ } | {
9
+ readonly ok: false;
10
+ readonly reason: string;
11
+ };
12
+ /**
13
+ * Resolve the scaffolded project's dependency graph into `pnpm-lock.yaml`.
14
+ *
15
+ * This is what makes the template's `pnpm install --frozen-lockfile` build
16
+ * possible: the lockfile cannot be shipped with the template, because the
17
+ * dependency set it would pin does not exist until `scaffold` has replaced
18
+ * every `workspace:*` with a published version (#289). It is therefore
19
+ * resolved once, here, against the rewritten `package.json`. Best-effort by
20
+ * design — it needs a package manager and a reachable registry — so failure
21
+ * is a result, not a throw.
22
+ */
23
+ export declare const generateLockfile: (targetDir: string) => LockfileResult;
4
24
  export declare const main: () => void;
5
25
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAmCA,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IAsCjE,CAAC;AAsDF,eAAO,MAAM,IAAI,QAAO,IA+CvB,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAoCA,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IAyEjE,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,cAAc,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAqBnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AA0DF,eAAO,MAAM,IAAI,QAAO,IAmEvB,CAAC"}
package/dist/index.mjs CHANGED
@@ -13,7 +13,8 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { spawnSync } from "node:child_process";
17
+ import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
17
18
  import { dirname, resolve } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { shouldCopyTemplateEntry } from "./internal/template-filter.mjs";
@@ -52,11 +53,26 @@ export const scaffold = (targetDir, projectName) => {
52
53
  recursive: true,
53
54
  filter: (source) => shouldCopyTemplateEntry(source, TEMPLATES_DIR),
54
55
  });
56
+ // #407: npm drops a file literally named `.gitignore` from a published
57
+ // package, so `copy-templates.mjs` stages it dot-less and it is restored
58
+ // here. Without it the first `git add .` in a scaffolded project commits
59
+ // the `.env` and the signing key the README's own setup steps say to
60
+ // create — and it would have shipped that way silently, because the file
61
+ // is present in the repository and only missing from the tarball.
62
+ const stagedGitignore = resolve(targetDir, "gitignore");
63
+ if (existsSync(stagedGitignore)) {
64
+ renameSync(stagedGitignore, resolve(targetDir, ".gitignore"));
65
+ }
55
66
  // Rewrite package.json
56
67
  const pkgPath = resolve(targetDir, "package.json");
57
68
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
58
69
  pkg.name = projectName;
59
- delete pkg.private;
70
+ // `"private": true` is deliberately KEPT (o3co/auth.policy-verifier#126
71
+ // item 4, same code in both scaffolders). The scaffold is an identity
72
+ // provider — keys, config, policy — and deleting the field made an
73
+ // accidental `npm publish` succeed by default. Publishing a scaffolded
74
+ // service is the rare intent; the operator who has it states it by
75
+ // removing the field.
60
76
  // Replace all workspace:* references with per-package published versions
61
77
  const versions = getPackageVersions();
62
78
  for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
@@ -74,11 +90,82 @@ export const scaffold = (targetDir, projectName) => {
74
90
  }
75
91
  }
76
92
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
93
+ // pnpm ≥10.29 reads `onlyBuiltDependencies` only from pnpm-workspace.yaml,
94
+ // in single-package projects too — the template's former package.json
95
+ // `pnpm` block is silently ignored (#360). The template cannot ship the
96
+ // file: inside the monorepo it would shadow the workspace root for anyone
97
+ // running pnpm from the template directory. So the scaffold writes it,
98
+ // mirroring the allowlist the workspace root keeps for the same dependency.
99
+ writeFileSync(resolve(targetDir, "pnpm-workspace.yaml"), [
100
+ "# pnpm (>= 10.29) reads onlyBuiltDependencies from this file, even in a",
101
+ "# single-package project. bcrypt's install hook compiles the native addon",
102
+ "# on platforms with no shipped prebuild; without this allowlist pnpm",
103
+ "# silently skips the hook and bcrypt fails at require time there.",
104
+ "onlyBuiltDependencies:",
105
+ " - bcrypt",
106
+ "",
107
+ ].join("\n"));
108
+ };
109
+ /**
110
+ * `--lockfile-only` resolves the dependency graph without installing or
111
+ * running any script. `--ignore-workspace` keeps the new project's lockfile
112
+ * its own even when the target directory happens to sit inside somebody
113
+ * else's pnpm workspace.
114
+ */
115
+ const LOCKFILE_ARGS = ["install", "--lockfile-only", "--ignore-workspace"];
116
+ /**
117
+ * Launchers tried in order. `pnpm` on PATH is the common case; `corepack pnpm`
118
+ * answers the machine that has Node's bundled corepack but no global pnpm.
119
+ * The fallback fires only on ENOENT — the one launch error a different
120
+ * launcher can answer. A non-zero exit (offline, private registry,
121
+ * unpublished version) would fail identically through any launcher, and
122
+ * EACCES-shaped errors surface as themselves rather than as "package manager
123
+ * missing".
124
+ */
125
+ const LOCKFILE_LAUNCHERS = [["pnpm"], ["corepack", "pnpm"]];
126
+ /**
127
+ * Resolve the scaffolded project's dependency graph into `pnpm-lock.yaml`.
128
+ *
129
+ * This is what makes the template's `pnpm install --frozen-lockfile` build
130
+ * possible: the lockfile cannot be shipped with the template, because the
131
+ * dependency set it would pin does not exist until `scaffold` has replaced
132
+ * every `workspace:*` with a published version (#289). It is therefore
133
+ * resolved once, here, against the rewritten `package.json`. Best-effort by
134
+ * design — it needs a package manager and a reachable registry — so failure
135
+ * is a result, not a throw.
136
+ */
137
+ export const generateLockfile = (targetDir) => {
138
+ const attempts = [];
139
+ for (const launcher of LOCKFILE_LAUNCHERS) {
140
+ const [bin, ...prefix] = launcher;
141
+ const args = [...prefix, ...LOCKFILE_ARGS];
142
+ const printable = [bin, ...args].join(" ");
143
+ const result = spawnSync(bin, args, {
144
+ cwd: targetDir,
145
+ stdio: "inherit",
146
+ shell: false,
147
+ env: { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
148
+ });
149
+ if (result.error) {
150
+ attempts.push(`${printable}: ${result.error.message}`);
151
+ // Only "not on PATH" is worth asking a different launcher about.
152
+ if (result.error.code === "ENOENT")
153
+ continue;
154
+ break;
155
+ }
156
+ if (result.status === 0)
157
+ return { ok: true, command: printable };
158
+ const how = result.status === null ? `killed by signal ${result.signal}` : `exit code ${result.status}`;
159
+ attempts.push(`${printable}: ${how}`);
160
+ break;
161
+ }
162
+ return { ok: false, reason: attempts.join("; ") };
77
163
  };
78
164
  const parseArgs = (args) => {
79
165
  const positionals = [];
80
166
  let dir;
81
167
  let dirSeen = false;
168
+ let lockfile = true;
82
169
  for (let i = 0; i < args.length; i++) {
83
170
  const a = args[i];
84
171
  if (a === "--dir") {
@@ -96,6 +183,9 @@ const parseArgs = (args) => {
96
183
  dir = a.slice("--dir=".length);
97
184
  dirSeen = true;
98
185
  }
186
+ else if (a === "--no-lockfile") {
187
+ lockfile = false;
188
+ }
99
189
  else if (a.startsWith("-")) {
100
190
  // Treats `--` and any --unknown as an unknown flag.
101
191
  throw new Error(`unknown flag: ${a}`);
@@ -108,7 +198,7 @@ const parseArgs = (args) => {
108
198
  throw new Error("missing <project-name>");
109
199
  if (positionals.length > 1)
110
200
  throw new Error("too many positional arguments");
111
- return { projectName: positionals[0], dir };
201
+ return { projectName: positionals[0], dir, lockfile };
112
202
  };
113
203
  const deriveDirName = (projectName, dir) => {
114
204
  if (dir !== undefined)
@@ -134,11 +224,11 @@ export const main = () => {
134
224
  }
135
225
  catch (e) {
136
226
  console.error(`Error: ${e.message}`);
137
- console.error("Usage: @o3co/create-auth-provider <project-name> [--dir <dir-name>]");
227
+ console.error("Usage: @o3co/create-auth-provider <project-name> [--dir <dir-name>] [--no-lockfile]");
138
228
  console.error("<project-name> must be a valid npm package name (scoped like @scope/pkg, or unscoped).");
139
229
  process.exit(1);
140
230
  }
141
- const { projectName, dir } = parsed;
231
+ const { projectName, dir, lockfile } = parsed;
142
232
  if (!isValidProjectName(projectName)) {
143
233
  console.error("Error: <project-name> must be a valid npm package name (scoped like @scope/pkg, or unscoped; max 214 chars; no backslashes; no extra '/' beyond the single scope separator).");
144
234
  process.exit(1);
@@ -155,9 +245,22 @@ export const main = () => {
155
245
  }
156
246
  console.log(`Creating ${projectName}...`);
157
247
  scaffold(targetDir, projectName);
248
+ let lockfileGenerated = false;
249
+ if (lockfile) {
250
+ console.log("\nResolving dependencies into pnpm-lock.yaml...");
251
+ const result = generateLockfile(targetDir);
252
+ lockfileGenerated = result.ok;
253
+ if (!result.ok) {
254
+ console.error(`\nWarning: could not generate pnpm-lock.yaml (${result.reason}).`);
255
+ console.error(`Run 'pnpm install' in ${dirName} and commit pnpm-lock.yaml: the Dockerfile installs with --frozen-lockfile and will not build without it.`);
256
+ }
257
+ }
158
258
  console.log(`\nDone! Created ${projectName} at ${targetDir}`);
259
+ if (lockfileGenerated) {
260
+ console.log("\nCommit pnpm-lock.yaml along with the rest: it is what makes 'docker build' reproducible.");
261
+ }
159
262
  console.log(`\nNext steps:`);
160
263
  console.log(` cd ${dirName}`);
161
- console.log(" npm install");
162
- console.log(" npm run debug");
264
+ console.log(" pnpm install");
265
+ console.log(" pnpm run debug");
163
266
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -25,11 +25,11 @@
25
25
  "templates"
26
26
  ],
27
27
  "engines": {
28
- "node": ">=18.19.0"
28
+ "node": ">=22.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "^25.9.1",
32
- "vitest": "^4.1.8"
31
+ "@types/node": "^26.2.0",
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -1,21 +1,65 @@
1
- # Required
2
- OAUTH_JWT_SECRET=
1
+ # Canonical issuer stamped as `iss` on every token this deployment mints.
2
+ # Absolute https URL (http only for a loopback host), no query or fragment.
3
+ # Boot fails when unset — it is never derived from the Host header.
4
+ OAUTH_JWT_ISSUER=
5
+
6
+ # --- JWT signing key (required) ---------------------------------------------
7
+ # The default algorithm is EdDSA (Ed25519). There is no key-material default:
8
+ # a deployment that sets none fails at boot naming the keys to set, rather than
9
+ # quietly signing with something guessable.
10
+ #
11
+ # openssl genpkey -algorithm ed25519 -out jwt-private.pem
12
+ # openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
13
+ #
14
+ # Keep jwt-private.pem out of version control and off the image.
15
+ OAUTH_JWT_PRIVATE_KEY_PATH=./jwt-private.pem
16
+ OAUTH_JWT_PUBLIC_KEY_PATH=./jwt-public.pem
17
+ # OAUTH_JWT_ALGORITHM=EdDSA # EdDSA | ES256 | RS256 | HS256
18
+ # OAUTH_JWT_KID=v0
19
+ #
20
+ # HS256 alternative — symmetric, so /.well-known/jwks.json publishes NOTHING
21
+ # and every relying party must hold a secret that can also MINT tokens. Only
22
+ # for deployments that verify in-process. Needs >= 32 bytes (256 bits):
23
+ # openssl rand -hex 32
24
+ # Hex/base64 values are measured DECODED, so a 32-character hex string counts
25
+ # as 16 bytes and is refused.
26
+ # OAUTH_JWT_ALGORITHM=HS256
27
+ # OAUTH_JWT_SECRET=
28
+
29
+ # --- Session (required) ------------------------------------------------------
30
+ # Signs the cookie that IS the authenticated session, so guessing it forges
31
+ # logins. Same >= 32-byte floor and same decoded measurement:
32
+ # openssl rand -hex 32
3
33
  SESSION_SECRET=
4
34
 
5
35
  # User repository
36
+ # Both URLs carry plaintext user credentials to the upstream store, so both must
37
+ # be absolute https URLs. http:// is accepted only for a loopback host
38
+ # (localhost, 127.0.0.0/8, [::1]) — a private-range address or a compose service
39
+ # name such as http://user-service:8080 still needs https. Boot fails otherwise.
6
40
  CLIENT_USER_AUTHENTICATE_URL=
7
41
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
8
42
 
9
- # Redis (code repository + session storage)
43
+ # Redis (code repository + session storage + the shared client socket)
10
44
  # Use redis://redis:6379 when running via docker compose
11
45
  CLIENT_CODE_ENDPOINT_URI=redis://redis:6379
12
46
  SESSION_STORAGE_REDIS_URL=redis://redis:6379
47
+ # #407: this one backs the SHARED ioredis socket -- the refresh-token family
48
+ # store, the access-token denylist and the rate limiter all ride on it. It
49
+ # defaults to redis://localhost:6379, which inside the container is the
50
+ # container, so leaving it unset makes the compose boot dial nothing.
51
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
13
52
 
14
- # Development (override for local HTTP)
53
+ # Development (override for local HTTP).
54
+ # The default cookie name carries the `__Host-` prefix, which browsers only
55
+ # accept on a Secure cookie — so dropping Secure means renaming the cookie too,
56
+ # or boot refuses the combination.
15
57
  SESSION_SECURE=false
58
+ SESSION_NAME=auth.sid
16
59
 
17
60
  # Optional
18
61
  # HTTP_PORT=3000
19
- # OAUTH_JWT_ISSUER=
20
62
  # OAUTH_ACCESS_TOKEN_EXPIRES_IN=3600
21
63
  # OAUTH_REFRESH_TOKEN_EXPIRES_IN=86400
64
+ # SESSION_MAX_AGE=3600000
65
+ # SESSION_SAME_SITE=lax # `none` additionally requires SESSION_SECURE=true
@@ -1,9 +1,18 @@
1
- FROM node:24-alpine AS node-base
1
+ # The base image is pinned by digest, not just by tag: `node:24-alpine` is a
2
+ # moving pointer, so the same source produced a different image on every
3
+ # rebuild (#289). The digest is the multi-arch index, so amd64 and arm64 both
4
+ # resolve from it. Dependabot's `docker` ecosystem keeps the pair in step —
5
+ # bump the tag and the digest together, never the tag alone.
6
+ FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43 AS node-base
2
7
 
3
8
  ENV HOME=/home/node
4
9
 
10
+ # corepack is pinned too. `npm install -g corepack --force` resolved whatever
11
+ # was latest at build time, which is a mutable dependency of every build. The
12
+ # pnpm version itself comes from the `packageManager` field in package.json,
13
+ # which corepack reads.
5
14
  RUN apk add --no-cache tini \
6
- && npm install -g corepack --force \
15
+ && npm install -g corepack@0.35.0 --force \
7
16
  && corepack enable
8
17
 
9
18
  WORKDIR /home/node
@@ -11,11 +20,25 @@ WORKDIR /home/node
11
20
  #############################################
12
21
  FROM node-base AS deps
13
22
 
14
- COPY package.json ./
23
+ # pnpm-lock.yaml is a required build input: `create-auth-provider` generates
24
+ # it at scaffold time and the project commits it. Without it this COPY fails,
25
+ # which is the intended outcome — a build that silently re-resolves every
26
+ # dependency range is the thing this file stopped doing (#289).
27
+ #
28
+ # pnpm-workspace.yaml is required for the same reason and was missing (#407):
29
+ # the scaffolder generates it to carry bcrypt's `onlyBuiltDependencies`
30
+ # allowlist, and pnpm >= 10.29 reads that setting ONLY from this file, in
31
+ # single-package projects too (#360). Installing without it runs
32
+ # allowlist-less, so pnpm silently skips bcrypt's build hook — which costs
33
+ # nothing while an alpine prebuild exists, and re-creates #360 the day one
34
+ # is missing and the native addon has to compile.
35
+ COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml ./
15
36
 
16
37
  USER node
17
38
 
18
- RUN pnpm install
39
+ # --frozen-lockfile: install exactly what the lockfile pins, and fail if
40
+ # package.json and the lockfile disagree, rather than quietly rewriting it.
41
+ RUN pnpm install --frozen-lockfile
19
42
 
20
43
  #############################################
21
44
  FROM deps AS builder
@@ -24,33 +47,53 @@ FROM deps AS builder
24
47
  # hardening invariant obvious in Dockerfile reviews.
25
48
  USER node
26
49
 
27
- COPY tsconfig.json ./
28
- COPY config/ ./config/
29
- COPY src/ ./src/
50
+ COPY --chown=node:node tsconfig.json ./
51
+ COPY --chown=node:node config/ ./config/
52
+ COPY --chown=node:node src/ ./src/
30
53
 
31
54
  RUN pnpm run build
32
55
 
33
56
  #############################################
34
- FROM node-base AS runtime
57
+ # Dependencies are resolved once, in `deps`. This stage drops the dev half of
58
+ # that same tree instead of running a second `pnpm install --prod` in the
59
+ # runtime stage: a second install is a second resolution, and therefore a
60
+ # second chance for the runtime image to hold something the build never saw.
61
+ FROM deps AS prod-deps
35
62
 
36
- ENV NODE_ENV=production
63
+ USER node
37
64
 
38
- COPY --from=deps /home/node/package.json /home/node/pnpm-lock.yaml ./
65
+ RUN pnpm prune --prod
39
66
 
40
- USER node
67
+ #############################################
68
+ FROM node-base AS runtime
41
69
 
42
- RUN pnpm install --prod \
43
- && pnpm store prune
70
+ ENV NODE_ENV=production
44
71
 
72
+ COPY --from=deps /home/node/package.json /home/node/pnpm-lock.yaml ./
73
+ COPY --from=prod-deps /home/node/node_modules/ ./node_modules/
45
74
  COPY --from=builder /home/node/dist/ ./dist/
46
75
  COPY --from=builder /home/node/config/ ./config/
47
76
 
77
+ USER node
78
+
48
79
  ENV HTTP_PORT=3000
49
80
  EXPOSE ${HTTP_PORT}
50
81
 
82
+ # Probes /_healthcheck (liveness), NOT /readyz. Docker has exactly one health
83
+ # signal, and on Swarm / ECS / autoheal-style supervisors an unhealthy
84
+ # container is stopped and replaced. Pointing it at /readyz would mean a 90s
85
+ # Redis failover marks every replica unhealthy and gets them all rescheduled —
86
+ # which reconnects nothing and adds cold starts to the incident. Losing Redis
87
+ # is a reason to stop routing to a replica, not to kill it.
88
+ #
89
+ # /readyz is the load-balancer / Kubernetes readinessProbe target; see the
90
+ # "Health endpoints" section of README.md. A compose file that genuinely wants
91
+ # `depends_on: condition: service_healthy` to mean "ready" can override this
92
+ # per-service, which keeps the restart-driving signal out of it.
93
+ #
51
94
  # Use the shell form so ${HTTP_PORT} expands at probe time. The HOCON
52
95
  # config reads `${?HTTP_PORT}` for `http.port`, so app.listen() and the
53
- # healthcheck stay in sync regardless of the operator's port choice.
96
+ # probe stay in sync regardless of the operator's port choice.
54
97
  HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
55
98
  CMD wget -q -O /dev/null "http://localhost:${HTTP_PORT}/_healthcheck" || exit 1
56
99
 
@@ -60,8 +103,8 @@ CMD ["node", "dist/app.mjs"]
60
103
  #############################################
61
104
  FROM builder AS test
62
105
 
63
- COPY vitest.config.mts ./
64
- COPY tests/ ./tests/
106
+ COPY --chown=node:node vitest.config.mts ./
107
+ COPY --chown=node:node tests/ ./tests/
65
108
 
66
109
  CMD ["pnpm", "run", "test", "--", "--passWithNoTests"]
67
110
 
@@ -42,27 +42,49 @@ overlay の値は `application.conf` を上書きします。scaffold には
42
42
  | 変数 | デフォルト | 説明 |
43
43
  |---|---|---|
44
44
  | `HTTP_PORT` | `3000` | サーバーがリッスンするポート |
45
- | `HTTP_TRUST_PROXY` | `false` | リバースプロキシの背後で動作する場合は `true` に設定 |
45
+ | `HTTP_TRUST_PROXY` | `false` | Express の `trust proxy`。`false` / アドレス・CIDR のリスト (`10.0.0.0/8,loopback`) / ホップ数 (`1`) / `true` のいずれか。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する。`true` ではなく**実際のプロキシを名指しする**こと (#292): `true` は「接続してきた相手が誰であれ、その X-Forwarded-For の先頭を信じる」という意味で、プロセスに直接到達できる相手が自分で `req.ip` を選べてしまう。詳細は [Multi-replica deployments](#multi-replica-deployments) |
46
46
 
47
47
  ### OAuth JWT
48
48
 
49
49
  | 変数 | デフォルト | 説明 |
50
50
  |---|---|---|
51
- | `OAUTH_JWT_ALGORITHM` | `HS256` | JWT 署名アルゴリズム(例: `RS256`、`ES256`) |
52
- | `OAUTH_JWT_SECRET` | — | 署名シークレット(HMAC アルゴリズム用) |
53
- | `OAUTH_JWT_ISSUER` | — | JWT の `iss` クレーム値 |
51
+ | `OAUTH_JWT_ALGORITHM` | `EdDSA` | JWT 署名アルゴリズム: `EdDSA` / `ES256` / `RS256` / `HS256`。デフォルトが非対称なので `/.well-known/jwks.json` が実際の検証鍵を公開する。 |
52
+ | `OAUTH_JWT_SECRET` | — | 署名シークレット(**HMAC (`HS256`) 専用**)。32 バイト(256 bit)以上のランダム値が必須 — `openssl rand -hex 32`。hex / base64 値は**デコード後**の長さで測るため、32 文字の hex 文字列は 16 バイト扱いで拒否される。 |
53
+ | `OAUTH_JWT_ISSUER` | **(必須)** | すべてのトークンの `iss` に刻まれる canonical issuer URL。絶対 `https` URL(`http` は loopback ホストのみ)で、query / fragment を含まないこと。未設定なら起動に失敗する — `Host` ヘッダから導出されることはない。 |
54
54
  | `OAUTH_JWT_KID` | `v0` | JWT ヘッダーに含まれる key ID |
55
55
  | `OAUTH_JWT_PRIVATE_KEY` | — | PEM エンコードされた秘密鍵(非対称アルゴリズム用) |
56
56
  | `OAUTH_JWT_PRIVATE_KEY_PATH` | — | PEM 秘密鍵ファイルのパス |
57
57
  | `OAUTH_JWT_PUBLIC_KEY` | — | PEM エンコードされた公開鍵 |
58
58
  | `OAUTH_JWT_PUBLIC_KEY_PATH` | — | PEM 公開鍵ファイルのパス |
59
59
 
60
+ **署名鍵は必須。** デフォルトアルゴリズムは `EdDSA` で、鍵素材のデフォルト値は存在しない。
61
+ 何も設定しないデプロイは「推測可能なシークレットで黙って署名する」のではなく、設定すべき
62
+ キー名を明示して起動に失敗する。Ed25519 鍵ペアの生成:
63
+
64
+ ```bash
65
+ openssl genpkey -algorithm ed25519 -out jwt-private.pem
66
+ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
67
+ ```
68
+
69
+ 生成したら `OAUTH_JWT_PRIVATE_KEY_PATH` / `OAUTH_JWT_PUBLIC_KEY_PATH` を指すか、
70
+ `OAUTH_JWT_PRIVATE_KEY` / `OAUTH_JWT_PUBLIC_KEY` に PEM をインラインで渡す。
71
+
72
+ `HS256` も引き続き選択可能(`OAUTH_JWT_ALGORITHM=HS256` + 32 バイト以上の
73
+ `OAUTH_JWT_SECRET`)だが、代償を理解した上で選ぶこと: 対称鍵には公開鍵の片割れが
74
+ 存在しないため `/.well-known/jwks.json` は `404 jwks_not_published` を返し、
75
+ すべての RP に共有シークレットを渡す必要がある。それは検証だけでなく
76
+ **トークンの発行(偽造)** も可能にする鍵である。
77
+
60
78
  ### トークン有効期限
61
79
 
62
80
  | 変数 | デフォルト | 説明 |
63
81
  |---|---|---|
64
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | アクセストークンの有効期間(秒) |
65
- | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | リフレッシュトークンの有効期間(秒) |
82
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | `3600` | アクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
83
+ | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | リフレッシュトークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
84
+
85
+ これらを空文字で export すると fallback ではなく起動失敗になる: HOCON は `FOO=` を
86
+ `""` に解決し、それが `0` に coerce され、有効期間 0 は「発行時点で期限切れ」の
87
+ トークンを作るため。
66
88
 
67
89
  ### グラントタイプ
68
90
 
@@ -76,15 +98,25 @@ overlay の値は `application.conf` を上書きします。scaffold には
76
98
 
77
99
  | 変数 | デフォルト | 説明 |
78
100
  |---|---|---|
79
- | `SESSION_SECRET` | — | **必須。** セッション署名シークレット |
80
- | `SESSION_MAX_AGE` | `3600000` | セッション Cookie の最大有効期間(ミリ秒) |
101
+ | `SESSION_SECRET` | — | **必須。** 認証済みセッションそのものである Cookie に署名する鍵。推測されればログインを偽造できる。32 バイト(256 bit)以上 — `openssl rand -hex 32`。`OAUTH_JWT_SECRET` と同じく**デコード後**の長さで測る。 |
102
+ | `SESSION_MAX_AGE` | `3600000` | セッション Cookie の最大有効期間(ミリ秒)。正の整数、上限は 1 年(`31536000000`)。 |
81
103
  | `SESSION_SECURE` | `true` | セッション Cookie に `Secure` フラグを設定 |
82
- | `SESSION_SAME_SITE` | `lax` | `SameSite` 属性(`lax`、`strict`、`none`) |
104
+ | `SESSION_SAME_SITE` | `lax` | `SameSite` 属性(`lax`、`strict`、`none`)。`none` は `SESSION_SECURE=true` が**必須** — ブラウザは `Secure` でない `SameSite=None` Cookie を破棄するため、クライアント側で全ログインが無言で失敗するのを避けて起動時に拒否する。 |
83
105
  | `SESSION_DOMAIN` | — | Cookie ドメイン(デフォルト未設定) |
106
+ | `SESSION_CSRF_TTL_SECONDS` | `7200` | 発行する CSRF トークンの有効期間(秒)。1〜86400 の整数で、外れると boot が失敗する(*空文字* は `0` に coerce され、トークン側の判定を無言で無効化してしまうため) |
84
107
  | `SESSION_STORAGE_TYPE` | `redis` | セッションストアのバックエンド: `redis` または `memory` |
85
108
  | `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | セッションストア用 Redis 接続 URL |
86
109
  | `SESSION_STORAGE_REDIS_PASSWORD` | — | セッションストア用 Redis パスワード |
87
110
 
111
+ #### `/session/login` と `/session/logout` の CSRF 対策
112
+
113
+ どちらのルートも、**same-origin(もしくは信頼した)`Origin` / `Referer`**、**または** 有効な double-submit CSRF トークンのいずれかを伴うリクエストのみ受理し、どちらも無いものは拒否する(#272)。ブラウザは自動的に条件を満たす。スクリプトのクライアントはまず `GET /session/csrf` を呼び、返ってきた `csrf_token` を `<SESSION_NAME>.csrf` cookie と `x-csrf-token` ヘッダー(または `csrf_token` フォームフィールド)の両方で送り返す。
114
+
115
+ 設定上の注意が 2 点:
116
+
117
+ - TLS 終端プロキシの背後では `HTTP_TRUST_PROXY` にそのプロキシのアドレス/CIDR を設定する (例: `HTTP_TRUST_PROXY=10.0.0.0/8`)。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。`true` は「プロキシ以外が絶対にプロセスへ到達できない」と言い切れる場合にのみ正しい (#292) — 英語版 README も同じ指針。
118
+ - ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF 信頼を与えなくなった。
119
+
88
120
  ### Google フェデレーション
89
121
 
90
122
  | 変数 | デフォルト | 説明 |
@@ -106,9 +138,12 @@ overlay の値は `application.conf` を上書きします。scaffold には
106
138
  | 変数 | デフォルト | 説明 |
107
139
  |---|---|---|
108
140
  | `CLIENT_USER_TYPE` | `http` | ユーザーリポジトリのバックエンド: `http` |
109
- | `CLIENT_USER_AUTHENTICATE_URL` | — | パスワード認証用のユーザー認証 URL |
110
- | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | トークン認証用のユーザー認証 URL |
111
- | `CLIENT_USER_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒) |
141
+ | `CLIENT_USER_AUTHENTICATE_URL` | — | パスワード認証用のユーザー認証 URL。**https 必須**(下記参照) |
142
+ | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | トークン認証用のユーザー認証 URL。**https 必須**(下記参照) |
143
+ | `CLIENT_USER_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒)。`2147483647` 以下の正の整数 |
144
+ | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | 上流レスポンスボディの受け入れ上限(バイト) |
145
+
146
+ ユーザー認証 URL は 2 つとも上流ストアへ**平文のユーザー資格情報**を運ぶため、いずれも絶対 `https://` URL でなければならない。`http://` は loopback ホスト(`localhost`、`127.0.0.0/8` 内のアドレス、`[::1]`)に限って許可され、ローカル開発で証明書を用意せずに済むようにしている。プライベートレンジのアドレス(`http://10.0.0.5/…`)やコンテナネットワークのサービス名(`http://user-service/…`)には `https://` が必要 — これらはデプロイが端から端まで制御していないネットワークを越えるため。URL・タイムアウト・レスポンス上限のいずれかが不正なら、最初のログイン時ではなく起動時に失敗する。
112
147
 
113
148
  ### コードリポジトリ
114
149
 
@@ -168,6 +203,38 @@ make test
168
203
 
169
204
  `docker-compose.yml` は認証サーバーと Redis コンテナをまとめて起動します。環境変数は `.env` で設定してください。
170
205
 
206
+ `docker-compose.yml` は**開発用**です(`develop` target をビルドし `./src` / `./config` を bind-mount するため、そのままデプロイすると working tree を hot-reload で配信します)。デプロイ可能な形は
207
+ [`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target・source mount なし・restart policy・ネットワーク内部限定の永続 Redis・**必須**の `.env`(実際の `SESSION_SECRET` が無い boot は大声で失敗すべき)です。TLS 終端 + `HTTP_TRUST_PROXY`、`--scale` 前の multi-replica 手順など「意図的に委ねる範囲」はファイル冒頭のコメントに明記しています。
208
+
209
+ ```bash
210
+ # 署名鍵は必須の入力です。デフォルトは EdDSA で鍵のデフォルト値は存在しないため、
211
+ # 鍵を生成していないデプロイは boot で失敗します。
212
+ openssl genpkey -algorithm ed25519 -out jwt-private.pem
213
+ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
214
+ chmod 600 jwt-private.pem
215
+
216
+ docker compose -f docker-compose.production.yml up -d --build
217
+ ```
218
+
219
+ **コンテナへの鍵の渡し方**。pem のペアは compose の **secret** として
220
+ `/run/secrets/` に read-only でマウントされ、compose 側が
221
+ `OAUTH_JWT_*_KEY_PATH` をそこに向けます。`config/` には置かないでください —
222
+ `Dockerfile` がこのディレクトリを `COPY` するため、置いた鍵はイメージレイヤに
223
+ 焼き込まれ、push のたびに一緒に運ばれます(保管場所を誤っただけでは済まず、
224
+ ローテーションが必要になります)。`.env` も同様に git の外に置いてください。
225
+
226
+ production ファイルは `environment:` で `SESSION_SECURE=true` と `__Host-`
227
+ cookie 名も固定します(`environment` は `env_file` より優先されます)。
228
+ `.env.example` は plain-HTTP のローカル実行のために `SESSION_SECURE=false` を
229
+ 出荷しており、このファイルはその同じ `.env` を要求するためです。前段で TLS を
230
+ 終端する前提の構成で非 Secure な session cookie を配ることは、平文区間を読める
231
+ 相手にセッションを渡すことと同じです。
232
+
233
+ イメージは `pnpm install --frozen-lockfile` でインストールするため、コミット済みの
234
+ `pnpm-lock.yaml` がビルドの必須入力です。`create-auth-provider` が scaffold 時に
235
+ 生成します。手元に無い場合は一度 `pnpm install` を実行して結果をコミットしてください。
236
+ これにより同じソースからのリビルドは同じ依存ツリーになります。
237
+
171
238
  ## カスタムモジュールの追加
172
239
 
173
240
  カスタムモジュールを追加するには、`src/buildModules.mts` でインポートし、`createApp` に渡す `modules` 配列に追加します。
@@ -204,9 +271,21 @@ const handle = await createApp({
204
271
 
205
272
  app.use(handle.router);
206
273
  const server = app.listen(config.http.port);
207
- gracefulShutdown(server, () => handle.dispose());
274
+ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
208
275
  ```
209
276
 
277
+ ### シャットダウンの保証
278
+
279
+ `src/shutdown.mts` は依存パッケージではなく scaffold 側に置いてある (#290)。全ユーザーセッションを終端するコンポーネントについて「SIGTERM は in-flight リクエストを待つのか、待つなら何秒か」は、デプロイするコード自体から答えられる必要があるため。
280
+
281
+ 1. **`SIGTERM` / `SIGINT`** のどちらでも開始する。2 回目のシグナルは無視する(1 回目の dispose に重ねて 2 回目を走らせない)。
282
+ 2. **新規接続は即座に停止**し、idle な keep-alive ソケットは解放する。リクエストを持たないまま server を開いたままにするソケットなので、解放しないと閑散時に deadline を丸ごと待つことになる。
283
+ 3. **in-flight リクエストには `drainTimeoutMs`**(既定 **10 秒**)を与える。
284
+ 4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
285
+ 5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
286
+
287
+ **`drainTimeoutMs` は orchestrator の kill grace period より短く設定すること** — Kubernetes の `terminationGracePeriodSeconds`、compose の `stop_grace_period` はいずれも既定 30 秒。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
288
+
210
289
  ## npm スクリプト
211
290
 
212
291
  | スクリプト | 説明 |