@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1
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/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- package/templates/versions.json +0 -20
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
* you may not use this file except in compliance with the License.
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* What the template writes while it starts: `src/app.mts` in a process of its
|
|
19
|
-
* own, in development on the memory stores, until it listens and then until
|
|
20
|
-
* it has shut down. Every line on stdout and on stderr is one JSON object from
|
|
21
|
-
* the structured logger — a warning a store gives at boot included.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { type ChildProcess, spawn } from "node:child_process";
|
|
25
|
-
import { generateKeyPairSync } from "node:crypto";
|
|
26
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
27
|
-
import { createRequire } from "node:module";
|
|
28
|
-
import { tmpdir } from "node:os";
|
|
29
|
-
import { join } from "node:path";
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
31
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
32
|
-
import { listeningRedis } from "./redis-stand-in.fixture.mjs";
|
|
33
|
-
|
|
34
|
-
const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
35
|
-
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
|
36
|
-
|
|
37
|
-
const keyPair = generateKeyPairSync("ed25519", {
|
|
38
|
-
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
39
|
-
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
/** An empty client registry: a scaffolded project's clone has no `config/clients.yaml`. */
|
|
43
|
-
const clientsFile = (() => {
|
|
44
|
-
const file = join(mkdtempSync(join(tmpdir(), "startup-log-lines-")), "clients.yaml");
|
|
45
|
-
writeFileSync(file, "");
|
|
46
|
-
return file;
|
|
47
|
-
})();
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The Redis the refresh-token family store's shared socket dials: a stand-in
|
|
51
|
-
* that reports `noeviction`, so the store passes its eviction gate and the run
|
|
52
|
-
* reaches no real server.
|
|
53
|
-
*/
|
|
54
|
-
let redis: Awaited<ReturnType<typeof listeningRedis>>;
|
|
55
|
-
|
|
56
|
-
beforeAll(async () => {
|
|
57
|
-
redis = await listeningRedis();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
afterAll(async () => {
|
|
61
|
-
await redis?.close();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Development, with every store the `adapters` section selects in memory.
|
|
66
|
-
* The refresh-token family store has no memory choice in the template; its
|
|
67
|
-
* shared Redis socket points at the stand-in above. Nothing is inherited from
|
|
68
|
-
* the test runner's environment but PATH, so its NODE_ENV and LOGGING_LEVEL do
|
|
69
|
-
* not reach the child.
|
|
70
|
-
*/
|
|
71
|
-
const env = (): Readonly<Record<string, string>> => ({
|
|
72
|
-
PATH: process.env.PATH ?? "",
|
|
73
|
-
CONFIG_ENV: "development",
|
|
74
|
-
NODE_ENV: "development",
|
|
75
|
-
LOGGING_LEVEL: "info",
|
|
76
|
-
HTTP_PORT: "0",
|
|
77
|
-
OAUTH_JWT_ISSUER: "http://localhost:3000",
|
|
78
|
-
KEY_STORE_LOCAL_PRIVATE_KEY: keyPair.privateKey,
|
|
79
|
-
KEY_STORE_LOCAL_PUBLIC_KEY: keyPair.publicKey,
|
|
80
|
-
SESSION_STORE_SECRET: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
|
81
|
-
SESSION_STORE_SECURE: "false",
|
|
82
|
-
SESSION_STORE_NAME: "auth.sid",
|
|
83
|
-
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
84
|
-
ADAPTERS_RATE_LIMITER: "memory",
|
|
85
|
-
ADAPTERS_ATTEMPT_COUNTER: "memory",
|
|
86
|
-
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
87
|
-
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
88
|
-
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
89
|
-
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
90
|
-
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
91
|
-
ADAPTERS_MFA_FACTOR_STORE: "memory",
|
|
92
|
-
ADAPTERS_MFA_TRANSACTION_STORE: "memory",
|
|
93
|
-
REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
|
|
94
|
-
REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
|
|
95
|
-
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "http://127.0.0.1:9/authenticate",
|
|
96
|
-
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "http://127.0.0.1:9/authenticate-by-token",
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
interface Run {
|
|
100
|
-
readonly stdout: string;
|
|
101
|
-
readonly stderr: string;
|
|
102
|
-
readonly listened: boolean;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Starts the template, stops it once it listens (or gives up), and returns what it wrote. */
|
|
106
|
-
function startAndStop(timeoutMs: number): Promise<Run> {
|
|
107
|
-
return new Promise((resolve, reject) => {
|
|
108
|
-
const child: ChildProcess = spawn(process.execPath, [tsxCli, "src/app.mts"], {
|
|
109
|
-
cwd: templateRoot,
|
|
110
|
-
env: env(),
|
|
111
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
112
|
-
});
|
|
113
|
-
let stdout = "";
|
|
114
|
-
let stderr = "";
|
|
115
|
-
let listened = false;
|
|
116
|
-
const stop = (): void => {
|
|
117
|
-
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
|
118
|
-
};
|
|
119
|
-
const deadline = setTimeout(stop, timeoutMs);
|
|
120
|
-
child.stdout?.setEncoding("utf8").on("data", (chunk: string) => {
|
|
121
|
-
stdout += chunk;
|
|
122
|
-
if (!listened && stdout.includes('"msg":"server_listening"')) {
|
|
123
|
-
listened = true;
|
|
124
|
-
stop();
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
child.stderr?.setEncoding("utf8").on("data", (chunk: string) => {
|
|
128
|
-
stderr += chunk;
|
|
129
|
-
});
|
|
130
|
-
child.on("error", (err) => {
|
|
131
|
-
clearTimeout(deadline);
|
|
132
|
-
reject(err);
|
|
133
|
-
});
|
|
134
|
-
child.on("close", () => {
|
|
135
|
-
clearTimeout(deadline);
|
|
136
|
-
resolve({ stdout, stderr, listened });
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Each line of `text` that is not one JSON object. */
|
|
142
|
-
function linesThatAreNotJsonObjects(text: string): string[] {
|
|
143
|
-
return text
|
|
144
|
-
.split("\n")
|
|
145
|
-
.filter((line) => line.trim() !== "")
|
|
146
|
-
.filter((line) => {
|
|
147
|
-
try {
|
|
148
|
-
const parsed: unknown = JSON.parse(line);
|
|
149
|
-
return typeof parsed !== "object" || parsed === null || Array.isArray(parsed);
|
|
150
|
-
} catch {
|
|
151
|
-
return true;
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
describe("the template's startup output", () => {
|
|
157
|
-
it("is one JSON object per line on stdout and stderr, in development on the memory stores", async () => {
|
|
158
|
-
const run = await startAndStop(15_000);
|
|
159
|
-
|
|
160
|
-
expect(run.listened, `the template did not listen; stderr:\n${run.stderr}`).toBe(true);
|
|
161
|
-
expect(linesThatAreNotJsonObjects(run.stdout)).toEqual([]);
|
|
162
|
-
expect(linesThatAreNotJsonObjects(run.stderr)).toEqual([]);
|
|
163
|
-
}, 30_000);
|
|
164
|
-
});
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
-
*
|
|
4
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
* you may not use this file except in compliance with the License.
|
|
6
|
-
* You may obtain a copy of the License at
|
|
7
|
-
*
|
|
8
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
*
|
|
10
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
* See the License for the specific language governing permissions and
|
|
14
|
-
* limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The credential auth.provider presents to the Store, through the shipped
|
|
19
|
-
* composition: `REPOSITORIES_USER_HTTP_BEARER_TOKEN` in the environment → the HOCON
|
|
20
|
-
* layers → the production `repositories` module's section → the
|
|
21
|
-
* `"http"` user adapter → the `Authorization` header a real `node:http` Store
|
|
22
|
-
* receives. Unset, no header is sent; exported but empty, the user repository
|
|
23
|
-
* is refused. From outside the booted app, a Store's `401` with a `Bearer`
|
|
24
|
-
* challenge turns `POST /session/login` into a `503` whose log line names the
|
|
25
|
-
* refused credential and never the token; a `401` without the challenge is
|
|
26
|
-
* still a wrong password.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
30
|
-
import { createServer, type OutgoingHttpHeaders, type Server } from "node:http";
|
|
31
|
-
import { tmpdir } from "node:os";
|
|
32
|
-
import { join } from "node:path";
|
|
33
|
-
import { fileURLToPath } from "node:url";
|
|
34
|
-
import { inspect } from "node:util";
|
|
35
|
-
import {
|
|
36
|
-
createApp,
|
|
37
|
-
createKeyStoreFactory,
|
|
38
|
-
defineModule,
|
|
39
|
-
type Logger,
|
|
40
|
-
memoryRefreshTokenFamilyStoreModule,
|
|
41
|
-
registerBuiltinKeyStores,
|
|
42
|
-
type UserRepository,
|
|
43
|
-
} from "@o3co/auth-provider-core";
|
|
44
|
-
import { parseFile } from "@o3co/ts.hocon";
|
|
45
|
-
import express from "express";
|
|
46
|
-
import request from "supertest";
|
|
47
|
-
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
48
|
-
import { buildModules } from "../buildModules.mjs";
|
|
49
|
-
import { resolveConfigPaths } from "../configPath.mjs";
|
|
50
|
-
import { repositoriesModuleFor, templateReference } from "../modules.mjs";
|
|
51
|
-
import { keyStoreSectionSchema, repositoriesSectionSchema } from "../sections.mjs";
|
|
52
|
-
import {
|
|
53
|
-
type BothPhases,
|
|
54
|
-
bothPhasesOf,
|
|
55
|
-
capturedRenames,
|
|
56
|
-
libraryLayers,
|
|
57
|
-
} from "./library-references.fixture.mjs";
|
|
58
|
-
|
|
59
|
-
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
60
|
-
|
|
61
|
-
/** 32 bytes of key material — what `openssl rand -hex 32` prints. */
|
|
62
|
-
const TOKEN = "7d1f0c3b9a5e2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f";
|
|
63
|
-
|
|
64
|
-
let servers: Server[] = [];
|
|
65
|
-
afterEach(async () => {
|
|
66
|
-
await Promise.all(
|
|
67
|
-
servers.map((server) => {
|
|
68
|
-
server.closeAllConnections();
|
|
69
|
-
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
servers = [];
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
/** How the Store answers every request. */
|
|
76
|
-
interface StoreAnswer {
|
|
77
|
-
readonly status: number;
|
|
78
|
-
readonly headers?: OutgoingHttpHeaders;
|
|
79
|
-
readonly body: unknown;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const USER: StoreAnswer = { status: 200, body: { id: "user-1", username: "alice" } };
|
|
83
|
-
|
|
84
|
-
/** A Store on its own loopback port that records each request's Authorization header. */
|
|
85
|
-
const recordingStore = async (
|
|
86
|
-
answer: StoreAnswer = USER,
|
|
87
|
-
): Promise<{ origin: string; heard: (string | undefined)[] }> => {
|
|
88
|
-
const heard: (string | undefined)[] = [];
|
|
89
|
-
const server = createServer((req, res) => {
|
|
90
|
-
req.resume();
|
|
91
|
-
req.on("end", () => {
|
|
92
|
-
heard.push(req.headers.authorization);
|
|
93
|
-
res.writeHead(answer.status, { "Content-Type": "application/json", ...answer.headers });
|
|
94
|
-
res.end(JSON.stringify(answer.body));
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
servers.push(server);
|
|
98
|
-
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
99
|
-
const { port } = server.address() as { port: number };
|
|
100
|
-
return { origin: `http://127.0.0.1:${port}`, heard };
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/** The shipped config for the production overlay, resolved against `env`. */
|
|
104
|
-
const resolve = (env: Record<string, string>): BothPhases => {
|
|
105
|
-
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
106
|
-
const layers = parseFile(envConfPath, { env })
|
|
107
|
-
.withFallback(parseFile(applicationConfPath, { env }))
|
|
108
|
-
.withFallback(parseFile(fileURLToPath(templateReference()), { env }))
|
|
109
|
-
.withFallback(libraryLayers(env));
|
|
110
|
-
return {
|
|
111
|
-
...bothPhasesOf(layers, env),
|
|
112
|
-
// What the resolution captured of the renamed variables.
|
|
113
|
-
"renamed-variables": capturedRenames(env),
|
|
114
|
-
} as BothPhases;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
/** The user repository the production `repositories` module builds from `config`'s section. */
|
|
118
|
-
const userRepositoryFrom = (config: BothPhases): Promise<UserRepository> =>
|
|
119
|
-
Promise.resolve(
|
|
120
|
-
repositoriesModuleFor({
|
|
121
|
-
client: config.adapters.clientRepository,
|
|
122
|
-
user: config.adapters.userRepository,
|
|
123
|
-
}).provides?.userRepository?.({
|
|
124
|
-
section: repositoriesSectionSchema.parse(
|
|
125
|
-
(config as unknown as Record<string, unknown>).repositories,
|
|
126
|
-
),
|
|
127
|
-
} as never) as UserRepository | Promise<UserRepository>,
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const envFor = (origin: string): Record<string, string> => ({
|
|
131
|
-
KEY_STORE_LOCAL_ALGORITHM: "HS256",
|
|
132
|
-
KEY_STORE_LOCAL_SECRET: "store-credential-composition.at-least-32-bytes.ok",
|
|
133
|
-
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
134
|
-
SESSION_STORE_SECRET: "store-credential-composition-session.at-least-32-bytes.ok",
|
|
135
|
-
ADAPTERS_USER_REPOSITORY: "http",
|
|
136
|
-
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: `${origin}/authenticate`,
|
|
137
|
-
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: `${origin}/authenticate-by-token`,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
describe("REPOSITORIES_USER_HTTP_BEARER_TOKEN reaches the Store through the shipped composition", () => {
|
|
141
|
-
it("sends Authorization: Bearer <token> on every Store call when the variable is set", async () => {
|
|
142
|
-
const { origin, heard } = await recordingStore();
|
|
143
|
-
const repo = await userRepositoryFrom(
|
|
144
|
-
resolve({ ...envFor(origin), REPOSITORIES_USER_HTTP_BEARER_TOKEN: TOKEN }),
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
await repo.authenticate("alice", "pass");
|
|
148
|
-
await repo.authenticateByToken("apple:a1");
|
|
149
|
-
|
|
150
|
-
expect(heard).toEqual([`Bearer ${TOKEN}`, `Bearer ${TOKEN}`]);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("sends no Authorization header when the variable is unset", async () => {
|
|
154
|
-
const { origin, heard } = await recordingStore();
|
|
155
|
-
const repo = await userRepositoryFrom(resolve(envFor(origin)));
|
|
156
|
-
|
|
157
|
-
await repo.authenticate("alice", "pass");
|
|
158
|
-
|
|
159
|
-
expect(heard).toEqual([undefined]);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("refuses the user repository when the variable is exported but empty", async () => {
|
|
163
|
-
const { origin, heard } = await recordingStore();
|
|
164
|
-
const config = resolve({ ...envFor(origin), REPOSITORIES_USER_HTTP_BEARER_TOKEN: "" });
|
|
165
|
-
|
|
166
|
-
await expect(userRepositoryFrom(config)).rejects.toThrow(/"bearerToken" must not be empty/);
|
|
167
|
-
expect(heard).toEqual([]);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("a token the Store refuses, seen from outside the booted app", () => {
|
|
172
|
-
/** Every store on memory, so the boot opens no socket but the Store's; MFA off. */
|
|
173
|
-
const MEMORY_ENV: Readonly<Record<string, string>> = {
|
|
174
|
-
MFA_MODE: "off",
|
|
175
|
-
SESSION_STORE_SECURE: "false",
|
|
176
|
-
SESSION_STORE_NAME: "auth.session",
|
|
177
|
-
SESSION_STORE_STORAGE_TYPE: "memory",
|
|
178
|
-
ADAPTERS_USER_SESSION_STORES: "memory",
|
|
179
|
-
ADAPTERS_RATE_LIMITER: "memory",
|
|
180
|
-
ADAPTERS_CODE_REPOSITORY: "memory",
|
|
181
|
-
ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
|
|
182
|
-
ADAPTERS_REPLAY_SEEN_SET: "memory",
|
|
183
|
-
ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
|
|
184
|
-
ADAPTERS_CONSENT_STORE: "none",
|
|
185
|
-
ADAPTERS_FEDERATION_GRANT_STORE: "memory",
|
|
186
|
-
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
// The template's `key-store` section, read by its own schema, without the
|
|
190
|
-
// section's relocation bridge.
|
|
191
|
-
const testKeyStoreModule = defineModule({
|
|
192
|
-
name: "key-store",
|
|
193
|
-
section: { schema: keyStoreSectionSchema },
|
|
194
|
-
provides: {
|
|
195
|
-
keyStore: async ({ section }) => {
|
|
196
|
-
const factory = createKeyStoreFactory();
|
|
197
|
-
registerBuiltinKeyStores(factory);
|
|
198
|
-
return factory.create({
|
|
199
|
-
type: "local",
|
|
200
|
-
...(section.local ?? {}),
|
|
201
|
-
});
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
/** Every log call, as the arguments it was made with. */
|
|
207
|
-
const capturingLogger = (): { logger: Logger; lines: { level: string; args: unknown[] }[] } => {
|
|
208
|
-
const lines: { level: string; args: unknown[] }[] = [];
|
|
209
|
-
const at =
|
|
210
|
-
(level: string) =>
|
|
211
|
-
(...args: unknown[]): void => {
|
|
212
|
-
lines.push({ level, args });
|
|
213
|
-
};
|
|
214
|
-
const logger = {
|
|
215
|
-
trace: at("trace"),
|
|
216
|
-
debug: at("debug"),
|
|
217
|
-
info: at("info"),
|
|
218
|
-
warn: at("warn"),
|
|
219
|
-
error: at("error"),
|
|
220
|
-
fatal: at("fatal"),
|
|
221
|
-
child: () => logger,
|
|
222
|
-
} as Logger;
|
|
223
|
-
return { logger, lines };
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
227
|
-
afterEach(async () => {
|
|
228
|
-
await handleRef?.dispose();
|
|
229
|
-
handleRef = undefined;
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* The client registry the boot reads, which this test brings itself.
|
|
234
|
-
* `REPOSITORIES_CLIENT_YAML_PATH`'s default, `./config/clients.yaml`, is per-deployment: the
|
|
235
|
-
* scaffold's `.gitignore` keeps it out of the project's repository, so a
|
|
236
|
-
* fresh clone — a CI runner, the `test` image — has none. The login below
|
|
237
|
-
* names no client, so an empty registry is all it needs.
|
|
238
|
-
*/
|
|
239
|
-
let registryDir: string;
|
|
240
|
-
let clientPath: string;
|
|
241
|
-
beforeAll(() => {
|
|
242
|
-
registryDir = mkdtempSync(join(tmpdir(), "store-credential-"));
|
|
243
|
-
clientPath = join(registryDir, "clients.yaml");
|
|
244
|
-
writeFileSync(clientPath, "# No clients: the login under test names none.\n");
|
|
245
|
-
});
|
|
246
|
-
afterAll(() => {
|
|
247
|
-
rmSync(registryDir, { recursive: true, force: true });
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* The shipped config against a Store answering `answer`, booted with the
|
|
252
|
-
* production repositories module; then a real browser login — a CSRF pair,
|
|
253
|
-
* then the form post.
|
|
254
|
-
*/
|
|
255
|
-
const logInAgainst = async (answer: StoreAnswer) => {
|
|
256
|
-
const { origin, heard } = await recordingStore(answer);
|
|
257
|
-
const config = resolve({
|
|
258
|
-
...envFor(origin),
|
|
259
|
-
...MEMORY_ENV,
|
|
260
|
-
REPOSITORIES_CLIENT_YAML_PATH: clientPath,
|
|
261
|
-
REPOSITORIES_USER_HTTP_BEARER_TOKEN: TOKEN,
|
|
262
|
-
});
|
|
263
|
-
const { logger, lines } = capturingLogger();
|
|
264
|
-
handleRef = await createApp({
|
|
265
|
-
modules: buildModules(config, {
|
|
266
|
-
keyStoreModule: testKeyStoreModule,
|
|
267
|
-
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
268
|
-
}),
|
|
269
|
-
bootstrapComponents: { config, pathResolver: (s: string) => s, logger },
|
|
270
|
-
});
|
|
271
|
-
const app = express().use(handleRef.router);
|
|
272
|
-
|
|
273
|
-
const csrf = await request(app).get("/session/csrf");
|
|
274
|
-
expect(csrf.status).toBe(200);
|
|
275
|
-
const login = await request(app)
|
|
276
|
-
.post("/session/login")
|
|
277
|
-
.set("Cookie", csrf.headers["set-cookie"] as unknown as string[])
|
|
278
|
-
.set(csrf.body.header_name as string, csrf.body.csrf_token as string)
|
|
279
|
-
.type("form")
|
|
280
|
-
.send({ username: "alice", password: "correct-horse-battery-staple" });
|
|
281
|
-
const logged = lines.map((line) =>
|
|
282
|
-
inspect(line, { depth: Number.POSITIVE_INFINITY, showHidden: true }),
|
|
283
|
-
);
|
|
284
|
-
return { login, heard, logged };
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
it("answers the login 503 and logs the refused credential by name, never the token", async () => {
|
|
288
|
-
const { login, heard, logged } = await logInAgainst({
|
|
289
|
-
status: 401,
|
|
290
|
-
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
|
|
291
|
-
body: { error: "invalid_token" },
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
expect(heard).toEqual([`Bearer ${TOKEN}`]);
|
|
295
|
-
expect(login.status).toBe(503);
|
|
296
|
-
expect(login.body.error).toBe("temporarily_unavailable");
|
|
297
|
-
expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toHaveLength(
|
|
298
|
-
1,
|
|
299
|
-
);
|
|
300
|
-
expect(logged.filter((line) => line.includes(TOKEN))).toEqual([]);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it("still answers a 401 without the challenge as a wrong password", async () => {
|
|
304
|
-
const { login, heard, logged } = await logInAgainst({
|
|
305
|
-
status: 401,
|
|
306
|
-
body: { error: "invalid_credentials" },
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
expect(heard).toEqual([`Bearer ${TOKEN}`]);
|
|
310
|
-
expect(login.status).toBe(401);
|
|
311
|
-
expect(login.body.error).toBe("invalid_credentials");
|
|
312
|
-
expect(logged.filter((line) => /refused this deployment's credential/.test(line))).toEqual([]);
|
|
313
|
-
});
|
|
314
|
-
});
|