@o3co/create-auth-provider 0.8.0 → 0.10.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 (31) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +99 -6
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +43 -4
  6. package/templates/standalone/Dockerfile +51 -16
  7. package/templates/standalone/README.ja.md +79 -12
  8. package/templates/standalone/README.md +345 -17
  9. package/templates/standalone/config/application.conf +121 -4
  10. package/templates/standalone/config/clients.yaml.example +41 -0
  11. package/templates/standalone/docker-compose.production.yml +116 -0
  12. package/templates/standalone/package.json +14 -18
  13. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  14. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
  15. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  16. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  17. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  18. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  19. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  20. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  21. package/templates/standalone/src/app.mts +67 -8
  22. package/templates/standalone/src/buildModules.mts +51 -1
  23. package/templates/standalone/src/configPath.mts +2 -2
  24. package/templates/standalone/src/logger.mts +90 -2
  25. package/templates/standalone/src/metrics.mts +190 -0
  26. package/templates/standalone/src/modules.mts +281 -33
  27. package/templates/standalone/src/terminalError.mts +60 -0
  28. package/templates/standalone/tests/index.test.js +69 -5
  29. package/templates/standalone/tsconfig.json +9 -1
  30. package/templates/standalone/vitest.config.mts +8 -0
  31. package/templates/versions.json +11 -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,IA8DjE,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,6 +13,7 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
+ import { spawnSync } from "node:child_process";
16
17
  import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
17
18
  import { dirname, resolve } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
@@ -56,7 +57,12 @@ export const scaffold = (targetDir, projectName) => {
56
57
  const pkgPath = resolve(targetDir, "package.json");
57
58
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
58
59
  pkg.name = projectName;
59
- delete pkg.private;
60
+ // `"private": true` is deliberately KEPT (o3co/auth.policy-verifier#126
61
+ // item 4, same code in both scaffolders). The scaffold is an identity
62
+ // provider — keys, config, policy — and deleting the field made an
63
+ // accidental `npm publish` succeed by default. Publishing a scaffolded
64
+ // service is the rare intent; the operator who has it states it by
65
+ // removing the field.
60
66
  // Replace all workspace:* references with per-package published versions
61
67
  const versions = getPackageVersions();
62
68
  for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
@@ -74,11 +80,82 @@ export const scaffold = (targetDir, projectName) => {
74
80
  }
75
81
  }
76
82
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
83
+ // pnpm ≥10.29 reads `onlyBuiltDependencies` only from pnpm-workspace.yaml,
84
+ // in single-package projects too — the template's former package.json
85
+ // `pnpm` block is silently ignored (#360). The template cannot ship the
86
+ // file: inside the monorepo it would shadow the workspace root for anyone
87
+ // running pnpm from the template directory. So the scaffold writes it,
88
+ // mirroring the allowlist the workspace root keeps for the same dependency.
89
+ writeFileSync(resolve(targetDir, "pnpm-workspace.yaml"), [
90
+ "# pnpm (>= 10.29) reads onlyBuiltDependencies from this file, even in a",
91
+ "# single-package project. bcrypt's install hook compiles the native addon",
92
+ "# on platforms with no shipped prebuild; without this allowlist pnpm",
93
+ "# silently skips the hook and bcrypt fails at require time there.",
94
+ "onlyBuiltDependencies:",
95
+ " - bcrypt",
96
+ "",
97
+ ].join("\n"));
98
+ };
99
+ /**
100
+ * `--lockfile-only` resolves the dependency graph without installing or
101
+ * running any script. `--ignore-workspace` keeps the new project's lockfile
102
+ * its own even when the target directory happens to sit inside somebody
103
+ * else's pnpm workspace.
104
+ */
105
+ const LOCKFILE_ARGS = ["install", "--lockfile-only", "--ignore-workspace"];
106
+ /**
107
+ * Launchers tried in order. `pnpm` on PATH is the common case; `corepack pnpm`
108
+ * answers the machine that has Node's bundled corepack but no global pnpm.
109
+ * The fallback fires only on ENOENT — the one launch error a different
110
+ * launcher can answer. A non-zero exit (offline, private registry,
111
+ * unpublished version) would fail identically through any launcher, and
112
+ * EACCES-shaped errors surface as themselves rather than as "package manager
113
+ * missing".
114
+ */
115
+ const LOCKFILE_LAUNCHERS = [["pnpm"], ["corepack", "pnpm"]];
116
+ /**
117
+ * Resolve the scaffolded project's dependency graph into `pnpm-lock.yaml`.
118
+ *
119
+ * This is what makes the template's `pnpm install --frozen-lockfile` build
120
+ * possible: the lockfile cannot be shipped with the template, because the
121
+ * dependency set it would pin does not exist until `scaffold` has replaced
122
+ * every `workspace:*` with a published version (#289). It is therefore
123
+ * resolved once, here, against the rewritten `package.json`. Best-effort by
124
+ * design — it needs a package manager and a reachable registry — so failure
125
+ * is a result, not a throw.
126
+ */
127
+ export const generateLockfile = (targetDir) => {
128
+ const attempts = [];
129
+ for (const launcher of LOCKFILE_LAUNCHERS) {
130
+ const [bin, ...prefix] = launcher;
131
+ const args = [...prefix, ...LOCKFILE_ARGS];
132
+ const printable = [bin, ...args].join(" ");
133
+ const result = spawnSync(bin, args, {
134
+ cwd: targetDir,
135
+ stdio: "inherit",
136
+ shell: false,
137
+ env: { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
138
+ });
139
+ if (result.error) {
140
+ attempts.push(`${printable}: ${result.error.message}`);
141
+ // Only "not on PATH" is worth asking a different launcher about.
142
+ if (result.error.code === "ENOENT")
143
+ continue;
144
+ break;
145
+ }
146
+ if (result.status === 0)
147
+ return { ok: true, command: printable };
148
+ const how = result.status === null ? `killed by signal ${result.signal}` : `exit code ${result.status}`;
149
+ attempts.push(`${printable}: ${how}`);
150
+ break;
151
+ }
152
+ return { ok: false, reason: attempts.join("; ") };
77
153
  };
78
154
  const parseArgs = (args) => {
79
155
  const positionals = [];
80
156
  let dir;
81
157
  let dirSeen = false;
158
+ let lockfile = true;
82
159
  for (let i = 0; i < args.length; i++) {
83
160
  const a = args[i];
84
161
  if (a === "--dir") {
@@ -96,6 +173,9 @@ const parseArgs = (args) => {
96
173
  dir = a.slice("--dir=".length);
97
174
  dirSeen = true;
98
175
  }
176
+ else if (a === "--no-lockfile") {
177
+ lockfile = false;
178
+ }
99
179
  else if (a.startsWith("-")) {
100
180
  // Treats `--` and any --unknown as an unknown flag.
101
181
  throw new Error(`unknown flag: ${a}`);
@@ -108,7 +188,7 @@ const parseArgs = (args) => {
108
188
  throw new Error("missing <project-name>");
109
189
  if (positionals.length > 1)
110
190
  throw new Error("too many positional arguments");
111
- return { projectName: positionals[0], dir };
191
+ return { projectName: positionals[0], dir, lockfile };
112
192
  };
113
193
  const deriveDirName = (projectName, dir) => {
114
194
  if (dir !== undefined)
@@ -134,11 +214,11 @@ export const main = () => {
134
214
  }
135
215
  catch (e) {
136
216
  console.error(`Error: ${e.message}`);
137
- console.error("Usage: @o3co/create-auth-provider <project-name> [--dir <dir-name>]");
217
+ console.error("Usage: @o3co/create-auth-provider <project-name> [--dir <dir-name>] [--no-lockfile]");
138
218
  console.error("<project-name> must be a valid npm package name (scoped like @scope/pkg, or unscoped).");
139
219
  process.exit(1);
140
220
  }
141
- const { projectName, dir } = parsed;
221
+ const { projectName, dir, lockfile } = parsed;
142
222
  if (!isValidProjectName(projectName)) {
143
223
  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
224
  process.exit(1);
@@ -155,9 +235,22 @@ export const main = () => {
155
235
  }
156
236
  console.log(`Creating ${projectName}...`);
157
237
  scaffold(targetDir, projectName);
238
+ let lockfileGenerated = false;
239
+ if (lockfile) {
240
+ console.log("\nResolving dependencies into pnpm-lock.yaml...");
241
+ const result = generateLockfile(targetDir);
242
+ lockfileGenerated = result.ok;
243
+ if (!result.ok) {
244
+ console.error(`\nWarning: could not generate pnpm-lock.yaml (${result.reason}).`);
245
+ console.error(`Run 'pnpm install' in ${dirName} and commit pnpm-lock.yaml: the Dockerfile installs with --frozen-lockfile and will not build without it.`);
246
+ }
247
+ }
158
248
  console.log(`\nDone! Created ${projectName} at ${targetDir}`);
249
+ if (lockfileGenerated) {
250
+ console.log("\nCommit pnpm-lock.yaml along with the rest: it is what makes 'docker build' reproducible.");
251
+ }
159
252
  console.log(`\nNext steps:`);
160
253
  console.log(` cd ${dirName}`);
161
- console.log(" npm install");
162
- console.log(" npm run debug");
254
+ console.log(" pnpm install");
255
+ console.log(" pnpm run debug");
163
256
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.8.0",
3
+ "version": "0.10.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.7.0",
32
- "vitest": "^4.1.6"
31
+ "@types/node": "^26.2.0",
32
+ "vitest": "^4.1.10"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -1,8 +1,42 @@
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
 
@@ -11,11 +45,16 @@ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
11
45
  CLIENT_CODE_ENDPOINT_URI=redis://redis:6379
12
46
  SESSION_STORAGE_REDIS_URL=redis://redis:6379
13
47
 
14
- # Development (override for local HTTP)
48
+ # Development (override for local HTTP).
49
+ # The default cookie name carries the `__Host-` prefix, which browsers only
50
+ # accept on a Secure cookie — so dropping Secure means renaming the cookie too,
51
+ # or boot refuses the combination.
15
52
  SESSION_SECURE=false
53
+ SESSION_NAME=auth.sid
16
54
 
17
55
  # Optional
18
56
  # HTTP_PORT=3000
19
- # OAUTH_JWT_ISSUER=
20
57
  # OAUTH_ACCESS_TOKEN_EXPIRES_IN=3600
21
58
  # OAUTH_REFRESH_TOKEN_EXPIRES_IN=86400
59
+ # SESSION_MAX_AGE=3600000
60
+ # 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,17 @@ 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
+ COPY --chown=node:node package.json pnpm-lock.yaml ./
15
28
 
16
29
  USER node
17
30
 
18
- RUN pnpm install
31
+ # --frozen-lockfile: install exactly what the lockfile pins, and fail if
32
+ # package.json and the lockfile disagree, rather than quietly rewriting it.
33
+ RUN pnpm install --frozen-lockfile
19
34
 
20
35
  #############################################
21
36
  FROM deps AS builder
@@ -24,33 +39,53 @@ FROM deps AS builder
24
39
  # hardening invariant obvious in Dockerfile reviews.
25
40
  USER node
26
41
 
27
- COPY tsconfig.json ./
28
- COPY config/ ./config/
29
- COPY src/ ./src/
42
+ COPY --chown=node:node tsconfig.json ./
43
+ COPY --chown=node:node config/ ./config/
44
+ COPY --chown=node:node src/ ./src/
30
45
 
31
46
  RUN pnpm run build
32
47
 
33
48
  #############################################
34
- FROM node-base AS runtime
49
+ # Dependencies are resolved once, in `deps`. This stage drops the dev half of
50
+ # that same tree instead of running a second `pnpm install --prod` in the
51
+ # runtime stage: a second install is a second resolution, and therefore a
52
+ # second chance for the runtime image to hold something the build never saw.
53
+ FROM deps AS prod-deps
35
54
 
36
- ENV NODE_ENV=production
55
+ USER node
37
56
 
38
- COPY --from=deps /home/node/package.json /home/node/pnpm-lock.yaml ./
57
+ RUN pnpm prune --prod
39
58
 
40
- USER node
59
+ #############################################
60
+ FROM node-base AS runtime
41
61
 
42
- RUN pnpm install --prod \
43
- && pnpm store prune
62
+ ENV NODE_ENV=production
44
63
 
64
+ COPY --from=deps /home/node/package.json /home/node/pnpm-lock.yaml ./
65
+ COPY --from=prod-deps /home/node/node_modules/ ./node_modules/
45
66
  COPY --from=builder /home/node/dist/ ./dist/
46
67
  COPY --from=builder /home/node/config/ ./config/
47
68
 
69
+ USER node
70
+
48
71
  ENV HTTP_PORT=3000
49
72
  EXPOSE ${HTTP_PORT}
50
73
 
74
+ # Probes /_healthcheck (liveness), NOT /readyz. Docker has exactly one health
75
+ # signal, and on Swarm / ECS / autoheal-style supervisors an unhealthy
76
+ # container is stopped and replaced. Pointing it at /readyz would mean a 90s
77
+ # Redis failover marks every replica unhealthy and gets them all rescheduled —
78
+ # which reconnects nothing and adds cold starts to the incident. Losing Redis
79
+ # is a reason to stop routing to a replica, not to kill it.
80
+ #
81
+ # /readyz is the load-balancer / Kubernetes readinessProbe target; see the
82
+ # "Health endpoints" section of README.md. A compose file that genuinely wants
83
+ # `depends_on: condition: service_healthy` to mean "ready" can override this
84
+ # per-service, which keeps the restart-driving signal out of it.
85
+ #
51
86
  # Use the shell form so ${HTTP_PORT} expands at probe time. The HOCON
52
87
  # config reads `${?HTTP_PORT}` for `http.port`, so app.listen() and the
53
- # healthcheck stay in sync regardless of the operator's port choice.
88
+ # probe stay in sync regardless of the operator's port choice.
54
89
  HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
55
90
  CMD wget -q -O /dev/null "http://localhost:${HTTP_PORT}/_healthcheck" || exit 1
56
91
 
@@ -60,8 +95,8 @@ CMD ["node", "dist/app.mjs"]
60
95
  #############################################
61
96
  FROM builder AS test
62
97
 
63
- COPY vitest.config.mts ./
64
- COPY tests/ ./tests/
98
+ COPY --chown=node:node vitest.config.mts ./
99
+ COPY --chown=node:node tests/ ./tests/
65
100
 
66
101
  CMD ["pnpm", "run", "test", "--", "--passWithNoTests"]
67
102
 
@@ -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` | リバースプロキシの背後で動作する場合は `true` に設定。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する |
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=true` を設定する。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。
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` 配列に追加します。