@o3co/create-auth-provider 0.10.0 → 0.12.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.map +1 -1
- package/dist/index.mjs +11 -1
- package/package.json +2 -2
- package/templates/standalone/.env.example +19 -1
- package/templates/standalone/Dockerfile +9 -1
- package/templates/standalone/README.ja.md +24 -4
- package/templates/standalone/README.md +175 -21
- package/templates/standalone/config/application.conf +28 -0
- package/templates/standalone/config/clients.yaml.example +15 -2
- package/templates/standalone/config/production.conf +9 -3
- package/templates/standalone/docker-compose.production.yml +80 -5
- package/templates/standalone/docker-compose.yml +24 -0
- package/templates/standalone/gitignore +21 -0
- package/templates/standalone/package.json +5 -6
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -4
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +246 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
- package/templates/standalone/src/app.mts +16 -3
- package/templates/standalone/src/buildModules.mts +54 -16
- package/templates/standalone/src/modules.mts +128 -24
- package/templates/standalone/src/shutdown.mts +219 -0
- package/templates/versions.json +13 -11
|
@@ -0,0 +1,246 @@
|
|
|
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
|
+
* Issue #407 — scaffold hardening from the v0.10.0 release-cut audit.
|
|
19
|
+
*
|
|
20
|
+
* These pin the parts of the scaffold that are checkable from inside the
|
|
21
|
+
* repository. The scaffold is the artifact an operator actually deploys, and
|
|
22
|
+
* every one of these was invisible until someone read the file: the container
|
|
23
|
+
* install losing an allowlist, the dev compose dialling a Redis that is not
|
|
24
|
+
* there, and one `git add .` committing the signing key.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
|
|
30
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
31
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
32
|
+
import { describe, expect, it } from "vitest";
|
|
33
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
34
|
+
|
|
35
|
+
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
36
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
37
|
+
const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `environment:` entries of a compose file's `app` service, as the
|
|
41
|
+
* environment the container would actually see.
|
|
42
|
+
*
|
|
43
|
+
* A hand-rolled reader rather than a YAML dependency: these two files are
|
|
44
|
+
* fixed-shape artifacts in this repository, the block is flat `KEY: value`
|
|
45
|
+
* scalars, and adding a parser to the template's dependency tree to read its
|
|
46
|
+
* own scaffold would be a strange thing to ship to operators.
|
|
47
|
+
*
|
|
48
|
+
* Compose's `${VAR:?err}` required-variable form resolves from the operator's
|
|
49
|
+
* shell or `.env`, so there is no literal value to record — the key is mapped
|
|
50
|
+
* to `null`, which is the point of that form and what the assertions below
|
|
51
|
+
* check for.
|
|
52
|
+
*/
|
|
53
|
+
function composeAppEnvironment(rel: string): Map<string, string | null> {
|
|
54
|
+
const lines = read(rel).split("\n");
|
|
55
|
+
const start = lines.findIndex((line) => /^\s{4}environment:\s*$/.test(line));
|
|
56
|
+
const env = new Map<string, string | null>();
|
|
57
|
+
if (start === -1) return env;
|
|
58
|
+
for (const line of lines.slice(start + 1)) {
|
|
59
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
60
|
+
// The block ends at the first line indented no further than its own key.
|
|
61
|
+
if (!/^\s{6}\S/.test(line)) break;
|
|
62
|
+
const match = /^\s{6}([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
|
|
63
|
+
if (!match) continue;
|
|
64
|
+
const raw = (match[2] as string).trim().replace(/^["']|["']$/g, "");
|
|
65
|
+
env.set(match[1] as string, /^\$\{[A-Z][A-Z0-9_]*:\?/.test(raw) ? null : raw);
|
|
66
|
+
}
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve the shipped config layers under a given environment, as `src/app.mts` does. */
|
|
71
|
+
function resolveWith(env: Record<string, string>, configEnv = "production"): AppConfig {
|
|
72
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
73
|
+
return validate(
|
|
74
|
+
parseFile(envConfPath, { env })
|
|
75
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
76
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
77
|
+
AppConfigSchema,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A compose file's `environment:` block, plus the secrets no compose file
|
|
83
|
+
* carries (they come from `.env` or a compose secret) — the environment the
|
|
84
|
+
* process would boot under.
|
|
85
|
+
*
|
|
86
|
+
* The key material follows what the file itself says: the production compose
|
|
87
|
+
* points the EdDSA key paths at its mounted secrets, so leave the default
|
|
88
|
+
* algorithm alone; the dev compose supplies no key at all, so give it the
|
|
89
|
+
* HS256 shape, whose `.strict()` union member is exactly why the two cannot
|
|
90
|
+
* share one map.
|
|
91
|
+
*/
|
|
92
|
+
function bootableEnv(rel: string): Record<string, string> {
|
|
93
|
+
const env: Record<string, string> = {
|
|
94
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
95
|
+
SESSION_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
|
|
96
|
+
};
|
|
97
|
+
for (const [key, value] of composeAppEnvironment(rel)) {
|
|
98
|
+
if (value !== null) env[key] = value;
|
|
99
|
+
}
|
|
100
|
+
if (env.OAUTH_JWT_PRIVATE_KEY_PATH === undefined) {
|
|
101
|
+
env.OAUTH_JWT_ALGORITHM = "HS256";
|
|
102
|
+
env.OAUTH_JWT_SECRET = "scaffold-assets-compose.at-least-32-bytes.ok";
|
|
103
|
+
}
|
|
104
|
+
return env;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
|
|
108
|
+
it("copies pnpm-workspace.yaml into the deps stage", () => {
|
|
109
|
+
// `create-auth-provider` generates it to carry the bcrypt
|
|
110
|
+
// `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
|
|
111
|
+
// setting ONLY from this file — in single-package projects too (#360).
|
|
112
|
+
// A deps stage that copies `package.json` and the lockfile but not this
|
|
113
|
+
// runs allowlist-less, which re-creates #360 the day an alpine prebuild
|
|
114
|
+
// is missing and bcrypt has to compile.
|
|
115
|
+
const dockerfile = read("/Dockerfile");
|
|
116
|
+
const depsStage = dockerfile.slice(
|
|
117
|
+
dockerfile.indexOf("FROM node-base AS deps"),
|
|
118
|
+
dockerfile.indexOf("FROM", dockerfile.indexOf("FROM node-base AS deps") + 1),
|
|
119
|
+
);
|
|
120
|
+
// Matched as a COPY instruction, not as a substring of the stage: the
|
|
121
|
+
// comment above that COPY names the file too, so a substring check
|
|
122
|
+
// would keep passing if the instruction regressed and the comment
|
|
123
|
+
// stayed — which is the failure this test exists to catch.
|
|
124
|
+
const copyLines = depsStage
|
|
125
|
+
.split("\n")
|
|
126
|
+
.filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
|
|
127
|
+
expect(copyLines.some((line) => /\bpnpm-workspace\.yaml\b/.test(line))).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("#407 — the dev compose can reach every Redis it configures", () => {
|
|
132
|
+
it("sets every *_REDIS_URL the template config reads", () => {
|
|
133
|
+
// The template's application.conf substitutes several Redis URLs, each
|
|
134
|
+
// defaulting to `redis://localhost:6379` — which inside the container is
|
|
135
|
+
// the container itself. `.env.example` is what the compose file loads,
|
|
136
|
+
// so a URL missing from it is a boot that dials nothing.
|
|
137
|
+
const conf = read("/config/application.conf");
|
|
138
|
+
const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
|
|
139
|
+
expect(declared.length).toBeGreaterThan(0);
|
|
140
|
+
|
|
141
|
+
const env = read("/.env.example");
|
|
142
|
+
const missing = [...new Set(declared)].filter(
|
|
143
|
+
(name) => !new RegExp(`^${name}=`, "m").test(env),
|
|
144
|
+
);
|
|
145
|
+
expect(missing).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("#407 — the scaffold does not invite committing its own secrets", () => {
|
|
150
|
+
it("ships a .gitignore", () => {
|
|
151
|
+
// Without one, the first `git add .` in a scaffolded project commits
|
|
152
|
+
// `.env` and `jwt-private.pem` — both of which the README tells the
|
|
153
|
+
// operator to create right there.
|
|
154
|
+
expect(existsSync(`${standaloneDir}/.gitignore`)).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("ignores the files the scaffold's own setup steps create", () => {
|
|
158
|
+
const ignored = read("/.gitignore");
|
|
159
|
+
for (const pattern of [".env", "*.pem", "node_modules", "dist"]) {
|
|
160
|
+
expect(ignored).toContain(pattern);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps .env.example tracked — it is the documentation", () => {
|
|
165
|
+
// A bare `.env*` would take the example with it, which is the mistake
|
|
166
|
+
// this pins against.
|
|
167
|
+
expect(read("/.gitignore")).toMatch(/^!\.env\.example$/m);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("#407 — the production compose matches the topology it documents", () => {
|
|
172
|
+
it("does not publish the app port on every interface", () => {
|
|
173
|
+
// The README says to keep `/metrics` off the public listener and the
|
|
174
|
+
// file assumes TLS is terminated in front, so a bare "3000:3000"
|
|
175
|
+
// contradicts both — it binds 0.0.0.0 on the host.
|
|
176
|
+
const compose = read("/docker-compose.production.yml");
|
|
177
|
+
expect(compose).not.toMatch(/^\s*-\s*"3000:3000"\s*$/m);
|
|
178
|
+
expect(compose).toMatch(/127\.0\.0\.1:3000:3000/);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("the compose files put a store and its lifetime-sibling on the same backend", () => {
|
|
183
|
+
// The production compose set `SESSION_STORAGE_TYPE: redis` and left
|
|
184
|
+
// `USER_SESSION_STORES_ADAPTER` at its `memory` default. express-session
|
|
185
|
+
// then survives a restart and the `UserSession` it points at does not, so
|
|
186
|
+
// every browser comes back `isAuthenticated` with nothing behind it and
|
|
187
|
+
// /authorize loops until the cookie is deleted by hand.
|
|
188
|
+
//
|
|
189
|
+
// `DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
|
|
190
|
+
// guard answers "can these stores be shared", not "do these two stores have
|
|
191
|
+
// the same lifetime" — so nothing but this assertion stands behind it.
|
|
192
|
+
for (const file of ["/docker-compose.production.yml", "/docker-compose.yml"]) {
|
|
193
|
+
it(`${file} keeps the user-session stores with the express-session store`, () => {
|
|
194
|
+
const config = resolveWith(bootableEnv(file));
|
|
195
|
+
// Resolved through the real config layers, not read off the file:
|
|
196
|
+
// what matters is the value the process ends up with, whether the
|
|
197
|
+
// compose stated it or `config/application.conf` did.
|
|
198
|
+
expect(config.session.storage?.type).toBe("redis");
|
|
199
|
+
expect(config.userSessionStores?.adapter).toBe("redis");
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
it("the production compose leaves no store on memory while a sibling is on Redis", () => {
|
|
204
|
+
const config = resolveWith(bootableEnv("/docker-compose.production.yml"));
|
|
205
|
+
// Every store whose records must outlive one process. The federation
|
|
206
|
+
// token store is deliberately absent: this template ships every
|
|
207
|
+
// federation disabled, so nothing writes to it, and turning it on needs
|
|
208
|
+
// an AES key the compose file must not invent.
|
|
209
|
+
expect(config.session.storage?.type).toBe("redis");
|
|
210
|
+
expect(config.userSessionStores?.adapter).toBe("redis");
|
|
211
|
+
expect(config.oauth.code?.adapter).toBe("redis");
|
|
212
|
+
expect(config.accessTokenDenylist?.adapter).toBe("redis");
|
|
213
|
+
expect(config.rateLimiter?.adapter).toBe("redis");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("the production compose refuses to guess HTTP_TRUST_PROXY", () => {
|
|
218
|
+
it("names the variable and supplies no default", () => {
|
|
219
|
+
// Without it the Secure cookie is never set, the CSRF origin check 403s
|
|
220
|
+
// every browser POST, and every IP-keyed rate limit shares one bucket.
|
|
221
|
+
// With a baked-in default it would silently trust a hop the operator
|
|
222
|
+
// never chose. The `${VAR:?err}` form is the only reading that is
|
|
223
|
+
// neither: compose refuses to start until the operator names their edge.
|
|
224
|
+
const compose = composeAppEnvironment("/docker-compose.production.yml");
|
|
225
|
+
expect(compose.has("HTTP_TRUST_PROXY")).toBe(true);
|
|
226
|
+
expect(compose.get("HTTP_TRUST_PROXY")).toBeNull();
|
|
227
|
+
expect(read("/docker-compose.production.yml")).not.toMatch(
|
|
228
|
+
/HTTP_TRUST_PROXY:\s*(true|false)\b/,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("is offered in .env.example with no value", () => {
|
|
233
|
+
// The compose reads it from `.env`, which the operator copies from here.
|
|
234
|
+
expect(read("/.env.example")).toMatch(/^HTTP_TRUST_PROXY=$/m);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("#407 — the two READMEs agree on security advice", () => {
|
|
239
|
+
it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
|
|
240
|
+
// #292 made `trust proxy` a CIDR/hop policy precisely because `true`
|
|
241
|
+
// means "believe the leftmost forwarded entry from whoever opened the
|
|
242
|
+
// connection". The English README warns against it; the Japanese one
|
|
243
|
+
// still recommended it, so the two disagreed on which is safe.
|
|
244
|
+
expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
* `POST /session/logout` must invalidate the token the `session` grant minted
|
|
19
|
+
* from the same browser session.
|
|
20
|
+
*
|
|
21
|
+
* This lives here rather than in `packages/session` or `packages/oauth`
|
|
22
|
+
* because it is the one assertion neither package can make alone: the logout
|
|
23
|
+
* endpoint is in `@o3co/auth-provider-session`, the introspection and userinfo
|
|
24
|
+
* endpoints are in `@o3co/auth-provider-oauth`, and the two are siblings that
|
|
25
|
+
* may not import each other. The standalone template is the composition root
|
|
26
|
+
* where both are mounted on one app, so it is where the contract between them
|
|
27
|
+
* is testable.
|
|
28
|
+
*
|
|
29
|
+
* What it pins: #506 stamped `sid` on the `session` grant's access token and
|
|
30
|
+
* gave `/oauth/introspect` the session-liveness check `/oauth/userinfo`
|
|
31
|
+
* already ran. Both resolve the `UserSession` record — which `/session/logout`
|
|
32
|
+
* used to leave alive, because it destroyed the cookie and nothing else. So in
|
|
33
|
+
* the BFF / `auth.proxy` injection topology, whose logout IS this endpoint, an
|
|
34
|
+
* access token minted from the session kept introspecting `active: true` and
|
|
35
|
+
* kept answering at `/userinfo` for its full lifetime after the user logged
|
|
36
|
+
* out. Only `/oauth/logout` with an `id_token_hint` was ever closed by #506.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
40
|
+
import {
|
|
41
|
+
type AppConfig,
|
|
42
|
+
createApp,
|
|
43
|
+
createKeyStoreFactory,
|
|
44
|
+
createReadinessRouter,
|
|
45
|
+
defineModule,
|
|
46
|
+
InMemoryClientRepository,
|
|
47
|
+
InMemoryUserRepository,
|
|
48
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
49
|
+
registerBuiltinKeyStores,
|
|
50
|
+
} from "@o3co/auth-provider-core";
|
|
51
|
+
import express from "express";
|
|
52
|
+
import request from "supertest";
|
|
53
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
54
|
+
import { buildModules } from "../buildModules.mjs";
|
|
55
|
+
|
|
56
|
+
const keyPair = generateKeyPairSync("ed25519", {
|
|
57
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
58
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const CLIENT_ID = "bff-client";
|
|
62
|
+
const CLIENT_SECRET = "bff-secret";
|
|
63
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
64
|
+
const USERNAME = "alice";
|
|
65
|
+
const PASSWORD = "correct-horse-battery-staple";
|
|
66
|
+
|
|
67
|
+
const config: AppConfig = {
|
|
68
|
+
http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
|
|
69
|
+
logging: { level: "silent" },
|
|
70
|
+
oauth: {
|
|
71
|
+
jwt: {
|
|
72
|
+
issuer: "https://auth.test",
|
|
73
|
+
signingKey: {
|
|
74
|
+
provider: "local",
|
|
75
|
+
local: {
|
|
76
|
+
algorithm: "EdDSA",
|
|
77
|
+
kid: "v0",
|
|
78
|
+
privateKey: keyPair.privateKey,
|
|
79
|
+
publicKey: keyPair.publicKey,
|
|
80
|
+
previousKeys: [],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
accessToken: { expiresIn: 3600 },
|
|
85
|
+
refreshToken: {
|
|
86
|
+
expiresIn: 86400,
|
|
87
|
+
unknownFamilyPolicy: "reject" as const,
|
|
88
|
+
legacyRtPolicy: "reject" as const,
|
|
89
|
+
},
|
|
90
|
+
// The grant this whole test is about: it mints straight from an
|
|
91
|
+
// authenticated browser session, which is the BFF topology.
|
|
92
|
+
grants: { session: { enabled: true } },
|
|
93
|
+
oidcMode: "oidc-required",
|
|
94
|
+
code: { adapter: "memory" as const },
|
|
95
|
+
},
|
|
96
|
+
session: {
|
|
97
|
+
secret: "test-session-secret.at-least-32-bytes.ok",
|
|
98
|
+
name: "auth.sid",
|
|
99
|
+
maxAge: 3600000,
|
|
100
|
+
secure: false,
|
|
101
|
+
sameSite: "lax",
|
|
102
|
+
domain: null,
|
|
103
|
+
storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
|
|
104
|
+
},
|
|
105
|
+
rateLimit: {
|
|
106
|
+
login: { windowMs: 60000, limit: 100 },
|
|
107
|
+
failMode: "open",
|
|
108
|
+
},
|
|
109
|
+
federations: { google: { enabled: false } },
|
|
110
|
+
repositories: {
|
|
111
|
+
client: { type: "yaml", path: "./config/clients.yaml" },
|
|
112
|
+
user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
|
|
113
|
+
code: { type: "memory", defaultExpiresIn: 600 },
|
|
114
|
+
},
|
|
115
|
+
endpoints: { login: { url: "/login" } },
|
|
116
|
+
cors: { allowedOrigins: [] },
|
|
117
|
+
} as unknown as AppConfig;
|
|
118
|
+
|
|
119
|
+
const testRepositoriesModule = defineModule({
|
|
120
|
+
name: "test:repositories",
|
|
121
|
+
provides: {
|
|
122
|
+
clientRepository: () =>
|
|
123
|
+
new InMemoryClientRepository(
|
|
124
|
+
new Map([
|
|
125
|
+
[
|
|
126
|
+
CLIENT_ID,
|
|
127
|
+
{
|
|
128
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
129
|
+
clientSecret: CLIENT_SECRET,
|
|
130
|
+
allowedRedirectUris: [],
|
|
131
|
+
allowedScopes: ["openid", "profile"],
|
|
132
|
+
allowedAudiences: [],
|
|
133
|
+
allowedGrantTypes: ["session"],
|
|
134
|
+
backchannelLogoutSessionRequired: true,
|
|
135
|
+
frontchannelLogoutSessionRequired: true,
|
|
136
|
+
allowedAzpForFederationToken: false,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
]),
|
|
140
|
+
),
|
|
141
|
+
userRepository: () =>
|
|
142
|
+
new InMemoryUserRepository(
|
|
143
|
+
new Map([[USERNAME, { id: "u-alice", password: PASSWORD, email: "alice@example.com" }]]),
|
|
144
|
+
),
|
|
145
|
+
} as never,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const testKeyStoreModule = defineModule({
|
|
149
|
+
name: "test:key-store",
|
|
150
|
+
requires: ["config"] as const,
|
|
151
|
+
provides: {
|
|
152
|
+
keyStore: async ({ config: c }) => {
|
|
153
|
+
const factory = createKeyStoreFactory();
|
|
154
|
+
registerBuiltinKeyStores(factory);
|
|
155
|
+
return factory.create({
|
|
156
|
+
type: "local",
|
|
157
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("POST /session/logout invalidates the session grant's access token", () => {
|
|
164
|
+
let handleRef: Awaited<ReturnType<typeof createApp>> | undefined;
|
|
165
|
+
|
|
166
|
+
afterEach(async () => {
|
|
167
|
+
await handleRef?.dispose();
|
|
168
|
+
handleRef = undefined;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
async function buildApp() {
|
|
172
|
+
const handle = await createApp({
|
|
173
|
+
modules: buildModules(config, {
|
|
174
|
+
keyStoreModule: testKeyStoreModule,
|
|
175
|
+
repositoriesModule: testRepositoriesModule,
|
|
176
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
177
|
+
}),
|
|
178
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
179
|
+
});
|
|
180
|
+
handleRef = handle;
|
|
181
|
+
|
|
182
|
+
const app = express();
|
|
183
|
+
app.use(
|
|
184
|
+
createReadinessRouter(express, {
|
|
185
|
+
probes: handle.readinessProbes,
|
|
186
|
+
timeoutMs: config.http.readinessTimeoutMs,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
app.use(handle.router);
|
|
190
|
+
return app;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drives the real browser half: fetch a CSRF pair, log in, and return the
|
|
195
|
+
* cookies plus the token the login response reissued. `/session/login`
|
|
196
|
+
* hands back a fresh CSRF cookie precisely so the follow-up logout needs no
|
|
197
|
+
* second round trip.
|
|
198
|
+
*/
|
|
199
|
+
async function login(app: express.Express) {
|
|
200
|
+
const csrfRes = await request(app).get("/session/csrf");
|
|
201
|
+
expect(csrfRes.status).toBe(200);
|
|
202
|
+
const csrfToken = csrfRes.body.csrf_token as string;
|
|
203
|
+
const headerName = csrfRes.body.header_name as string;
|
|
204
|
+
const csrfCookies = csrfRes.headers["set-cookie"] as unknown as string[];
|
|
205
|
+
|
|
206
|
+
const loginRes = await request(app)
|
|
207
|
+
.post("/session/login")
|
|
208
|
+
.set("Cookie", csrfCookies)
|
|
209
|
+
.set(headerName, csrfToken)
|
|
210
|
+
.type("form")
|
|
211
|
+
.send({ username: USERNAME, password: PASSWORD });
|
|
212
|
+
expect(loginRes.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
const loginCookies = loginRes.headers["set-cookie"] as unknown as string[];
|
|
215
|
+
// The session cookie comes from the login response, and so does a FRESH
|
|
216
|
+
// CSRF cookie — the login handler reissues one on the regenerated
|
|
217
|
+
// session so the follow-up logout needs no second round trip. The
|
|
218
|
+
// mechanism is a signed double-submit, so the cookie's value IS the
|
|
219
|
+
// token the header has to echo; the pre-login token no longer matches.
|
|
220
|
+
const csrfCookiePrefix = `${config.session.name}.csrf=`;
|
|
221
|
+
const reissued = loginCookies
|
|
222
|
+
.find((c) => c.startsWith(csrfCookiePrefix))
|
|
223
|
+
?.slice(csrfCookiePrefix.length)
|
|
224
|
+
.split(";")[0];
|
|
225
|
+
expect(reissued).toBeDefined();
|
|
226
|
+
return {
|
|
227
|
+
cookies: loginCookies,
|
|
228
|
+
csrfToken: decodeURIComponent(reissued as string),
|
|
229
|
+
headerName,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
it("a token minted from the session stops introspecting active after the session logs out", async () => {
|
|
234
|
+
const app = await buildApp();
|
|
235
|
+
const { cookies, csrfToken, headerName } = await login(app);
|
|
236
|
+
|
|
237
|
+
// The BFF exchanges the browser session for an access token.
|
|
238
|
+
const tokenRes = await request(app)
|
|
239
|
+
.post("/oauth/token")
|
|
240
|
+
.set("Authorization", BASIC)
|
|
241
|
+
.set("Cookie", cookies)
|
|
242
|
+
.type("form")
|
|
243
|
+
.send({ grant_type: "session", scope: "openid profile" });
|
|
244
|
+
expect(tokenRes.status).toBe(200);
|
|
245
|
+
const accessToken = tokenRes.body.access_token as string;
|
|
246
|
+
expect(typeof accessToken).toBe("string");
|
|
247
|
+
|
|
248
|
+
// Before the logout the token is a live credential at both surfaces.
|
|
249
|
+
const beforeIntrospect = await request(app)
|
|
250
|
+
.post("/oauth/introspect")
|
|
251
|
+
.set("Authorization", BASIC)
|
|
252
|
+
.type("form")
|
|
253
|
+
.send({ token: accessToken });
|
|
254
|
+
expect(beforeIntrospect.status).toBe(200);
|
|
255
|
+
expect(beforeIntrospect.body.active).toBe(true);
|
|
256
|
+
|
|
257
|
+
const beforeUserinfo = await request(app)
|
|
258
|
+
.get("/oauth/userinfo")
|
|
259
|
+
.set("Authorization", `Bearer ${accessToken}`);
|
|
260
|
+
expect(beforeUserinfo.status).toBe(200);
|
|
261
|
+
|
|
262
|
+
// The logout the BFF topology actually performs — no id_token_hint, no
|
|
263
|
+
// `/oauth/logout`, just the session endpoint the browser calls.
|
|
264
|
+
const logoutRes = await request(app)
|
|
265
|
+
.post("/session/logout")
|
|
266
|
+
.set("Cookie", cookies)
|
|
267
|
+
.set(headerName, csrfToken);
|
|
268
|
+
expect(logoutRes.status).toBe(200);
|
|
269
|
+
expect(logoutRes.body).toMatchObject({ message: "Logged out successfully" });
|
|
270
|
+
|
|
271
|
+
// After it, the same token no longer authorizes anything. This is the
|
|
272
|
+
// assertion that failed before the fix: the `UserSession` record the
|
|
273
|
+
// liveness checks resolve outlived the logout.
|
|
274
|
+
const afterIntrospect = await request(app)
|
|
275
|
+
.post("/oauth/introspect")
|
|
276
|
+
.set("Authorization", BASIC)
|
|
277
|
+
.type("form")
|
|
278
|
+
.send({ token: accessToken });
|
|
279
|
+
expect(afterIntrospect.status).toBe(200);
|
|
280
|
+
expect(afterIntrospect.body.active).toBe(false);
|
|
281
|
+
|
|
282
|
+
const afterUserinfo = await request(app)
|
|
283
|
+
.get("/oauth/userinfo")
|
|
284
|
+
.set("Authorization", `Bearer ${accessToken}`);
|
|
285
|
+
expect(afterUserinfo.status).toBe(401);
|
|
286
|
+
expect(afterUserinfo.body.error_description).toBe("session_invalid");
|
|
287
|
+
});
|
|
288
|
+
});
|