@o3co/create-auth-provider 0.9.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.
- package/dist/index.d.mts +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +99 -6
- package/package.json +4 -4
- package/templates/standalone/.env.example +43 -4
- package/templates/standalone/Dockerfile +51 -16
- package/templates/standalone/README.ja.md +79 -12
- package/templates/standalone/README.md +345 -17
- package/templates/standalone/config/application.conf +121 -4
- package/templates/standalone/config/clients.yaml.example +41 -0
- package/templates/standalone/docker-compose.production.yml +116 -0
- package/templates/standalone/package.json +14 -18
- package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +419 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
- package/templates/standalone/src/__tests__/smoke.test.mts +177 -11
- package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
- package/templates/standalone/src/app.mts +67 -8
- package/templates/standalone/src/buildModules.mts +43 -1
- package/templates/standalone/src/configPath.mts +2 -2
- package/templates/standalone/src/logger.mts +90 -2
- package/templates/standalone/src/metrics.mts +190 -0
- package/templates/standalone/src/modules.mts +281 -33
- package/templates/standalone/src/terminalError.mts +60 -0
- package/templates/standalone/tests/index.test.js +69 -5
- package/templates/standalone/tsconfig.json +9 -1
- package/templates/standalone/vitest.config.mts +8 -0
- 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
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"
|
|
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
|
-
|
|
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("
|
|
162
|
-
console.log("
|
|
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.
|
|
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": ">=
|
|
28
|
+
"node": ">=22.0.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/node": "^
|
|
32
|
-
"vitest": "^4.1.
|
|
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
|
-
#
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
USER node
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
RUN pnpm prune --prod
|
|
39
58
|
|
|
40
|
-
|
|
59
|
+
#############################################
|
|
60
|
+
FROM node-base AS runtime
|
|
41
61
|
|
|
42
|
-
|
|
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
|
-
#
|
|
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` | `
|
|
52
|
-
| `OAUTH_JWT_SECRET` | — |
|
|
53
|
-
| `OAUTH_JWT_ISSUER` |
|
|
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` 配列に追加します。
|