@o3co/create-auth-policy-verifier 0.8.0 → 0.9.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/package.json +1 -1
- package/templates/standalone/README.ja.md +3 -1
- package/templates/standalone/README.md +3 -1
- package/templates/standalone/config/application.conf +24 -0
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/loadConfig.test.mts +17 -17
- package/templates/standalone/src/__tests__/shutdown.test.mts +28 -0
- package/templates/standalone/src/shutdown.ts +9 -2
- package/templates/versions.json +3 -3
package/package.json
CHANGED
|
@@ -43,7 +43,9 @@ OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
|
|
|
43
43
|
| `OAUTH_JWT_JWKS_TIMEOUT_MS` | `5000` | JWKS 取得をこの時間で打ち切る |
|
|
44
44
|
| `OAUTH_JWT_JWKS_COOLDOWN_MS` | `30000` | JWKS 再取得の最小間隔 |
|
|
45
45
|
| `OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS` | `600000` | 取得した JWKS をキャッシュから返す期間 |
|
|
46
|
-
| `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | 受け入れる `typ` ヘッダ。同じ鍵で署名された id/refresh/logout token
|
|
46
|
+
| `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | 受け入れる `typ` ヘッダ。同じ鍵で署名された id/refresh/logout token を拒否する。`*` で pin しない(その場合 `audience` を OIDC client id にしてはいけない) |
|
|
47
|
+
| `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | audience を読む claim。`azp`(Clerk)、`client_id`(Cognito)。`aud` 以外を指定すると `aud` は一切見ない |
|
|
48
|
+
| `OAUTH_AUTHENTICATOR` | `jwt` | subject を確立する token authenticator。他の名前は `main.mts` で module が登録したもの |
|
|
47
49
|
| `OAUTH_JWT_MODE` | `verify` | `verify` はトークンを完全検証。明示的な `insecure-decode`(テスト専用)は署名検証なしでデコードする — `exp`/`nbf` は引き続き強制される |
|
|
48
50
|
| `RULE_ON_EMPTY_RULE_SET` | `deny` | ルールが 1 つも集まらなかったときの決定(`deny` \| `allow`) |
|
|
49
51
|
| `VERIFY_MAX_BATCH_SIZE` | `50` | `POST /verify/batch` の件数上限 |
|
|
@@ -43,7 +43,9 @@ Individual values can also be overridden with environment variables.
|
|
|
43
43
|
| `OAUTH_JWT_JWKS_TIMEOUT_MS` | `5000` | Abort a JWKS fetch after this long |
|
|
44
44
|
| `OAUTH_JWT_JWKS_COOLDOWN_MS` | `30000` | Minimum spacing between JWKS fetches |
|
|
45
45
|
| `OAUTH_JWT_JWKS_CACHE_MAX_AGE_MS` | `600000` | How long a fetched JWKS is served from cache |
|
|
46
|
-
| `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | Accepted `typ` header; rejects id/refresh/logout tokens signed with the same key |
|
|
46
|
+
| `OAUTH_JWT_TOKEN_TYPE` | `at+jwt` | Accepted `typ` header; rejects id/refresh/logout tokens signed with the same key. `*` pins nothing (then `audience` must not be an OIDC client id) |
|
|
47
|
+
| `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | Claim the audience is read from: `azp` (Clerk), `client_id` (Cognito). Anything but `aud` means `aud` is not consulted at all |
|
|
48
|
+
| `OAUTH_AUTHENTICATOR` | `jwt` | Which token authenticator establishes the subject; another name must be registered by a module in `main.mts` |
|
|
47
49
|
| `OAUTH_JWT_MODE` | `verify` | `verify` fully verifies tokens; the explicit `insecure-decode` (test-only) decodes without signature verification — `exp`/`nbf` are still enforced |
|
|
48
50
|
| `RULE_ON_EMPTY_RULE_SET` | `deny` | Decision when no rule is collected (`deny` \| `allow`) |
|
|
49
51
|
| `VERIFY_MAX_BATCH_SIZE` | `50` | Cap on `POST /verify/batch` entries |
|
|
@@ -35,6 +35,15 @@ http {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
oauth {
|
|
38
|
+
# Which token authenticator establishes the subject of a decision. "jwt"
|
|
39
|
+
# (the default) is the built-in bearer-JWT path the block below configures.
|
|
40
|
+
# A deployment whose IdP issues opaque tokens, or whose gateway already
|
|
41
|
+
# authenticated the caller, registers its own authenticator in main.mts and
|
|
42
|
+
# names it here — see docs/extending.md, "Writing a token authenticator".
|
|
43
|
+
# With another name the `jwt` block must be omitted — that authenticator's
|
|
44
|
+
# keys go under its own sub-block (oauth.<name> { … }).
|
|
45
|
+
authenticator = "jwt"
|
|
46
|
+
authenticator = ${?OAUTH_AUTHENTICATOR}
|
|
38
47
|
jwt {
|
|
39
48
|
algorithm = "HS256"
|
|
40
49
|
algorithm = ${?OAUTH_JWT_ALGORITHM}
|
|
@@ -120,6 +129,19 @@ oauth {
|
|
|
120
129
|
# rejects id_tokens, refresh tokens and logout tokens signed with the same key.
|
|
121
130
|
tokenType = "at+jwt"
|
|
122
131
|
tokenType = ${?OAUTH_JWT_TOKEN_TYPE}
|
|
132
|
+
# `tokenType = "*"` (exactly that) pins nothing — any typ, or none. For
|
|
133
|
+
# issuers whose tokens carry no typ header (Cognito). The audience is then
|
|
134
|
+
# the only thing telling an access token from an id_token signed with the
|
|
135
|
+
# same key, so `audience` must NOT be an OIDC client id under "*": use an
|
|
136
|
+
# API identifier, or an audienceClaim id_tokens lack (Cognito: client_id).
|
|
137
|
+
# The claim the audience is read from. "aud" (the default) is RFC 9068;
|
|
138
|
+
# "azp" binds a Clerk session token, "client_id" a Cognito access token.
|
|
139
|
+
# The check moves, it never goes away — `audience` above stays required.
|
|
140
|
+
# With anything but "aud" here, `aud` is NOT consulted at all: use it only
|
|
141
|
+
# for issuers that emit no aud, and never name a claim the user can write
|
|
142
|
+
# (Clerk unsafe_metadata, Auth0 user_metadata).
|
|
143
|
+
audienceClaim = "aud"
|
|
144
|
+
audienceClaim = ${?OAUTH_JWT_AUDIENCE_CLAIM}
|
|
123
145
|
|
|
124
146
|
# Bounds on a presented token's own lifetime. Both apply in EVERY mode,
|
|
125
147
|
# insecure-decode included. `exp` itself is required and has no setting:
|
|
@@ -176,6 +198,8 @@ attribute {
|
|
|
176
198
|
# can never pass — see the note in the rule block.
|
|
177
199
|
collectors = [
|
|
178
200
|
# The token's `scope` claim, split into the list the scope rule reads.
|
|
201
|
+
# `claim = "scp"` reads Okta's scope array instead; pair it with the same
|
|
202
|
+
# `claim` on ResourceActionScopeRuleCollector below (#219).
|
|
179
203
|
{ collector = "PayloadScopeCollector" }
|
|
180
204
|
# The token's `sub`, which becomes the `subject` on the decision response
|
|
181
205
|
# and the `sub` field on the audit log line.
|
|
@@ -89,12 +89,12 @@ describe("loadAppConfig", () => {
|
|
|
89
89
|
pathPrefix: "",
|
|
90
90
|
callerAuth: { header: "x-caller-token" },
|
|
91
91
|
});
|
|
92
|
-
expect(config.oauth.jwt
|
|
93
|
-
expect(config.oauth.jwt
|
|
94
|
-
expect(config.oauth.jwt
|
|
95
|
-
expect(config.oauth.jwt
|
|
96
|
-
expect(config.oauth.jwt
|
|
97
|
-
expect(config.oauth.jwt
|
|
92
|
+
expect(config.oauth.jwt?.algorithm).toBe("HS256");
|
|
93
|
+
expect(config.oauth.jwt?.secret).toBe(TEST_SECRET);
|
|
94
|
+
expect(config.oauth.jwt?.mode).toBe("verify");
|
|
95
|
+
expect(config.oauth.jwt?.issuer).toBe("https://issuer.test");
|
|
96
|
+
expect(config.oauth.jwt?.audience).toBe("https://api.test");
|
|
97
|
+
expect(config.oauth.jwt?.tokenType).toBe("at+jwt");
|
|
98
98
|
expect(config.verify.maxBatchSize).toBe(50);
|
|
99
99
|
},
|
|
100
100
|
);
|
|
@@ -170,7 +170,7 @@ describe("loadAppConfig", () => {
|
|
|
170
170
|
|
|
171
171
|
const config = loadAppConfig(configDirPath, "development");
|
|
172
172
|
|
|
173
|
-
expect(config.oauth.jwt
|
|
173
|
+
expect(config.oauth.jwt?.mode).toBe("insecure-decode");
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
it("rejects a boolean-ish OAUTH_JWT_MODE left over from the removed OAUTH_JWT_VALIDATE", () => {
|
|
@@ -237,11 +237,11 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
|
|
|
237
237
|
const config = loadAppConfig(configDirPath, "development");
|
|
238
238
|
|
|
239
239
|
expect(config.http.port).toBe(3000);
|
|
240
|
-
expect(config.oauth.jwt
|
|
241
|
-
expect(config.oauth.jwt
|
|
242
|
-
expect(config.oauth.jwt
|
|
243
|
-
expect(config.oauth.jwt
|
|
244
|
-
expect(config.oauth.jwt
|
|
240
|
+
expect(config.oauth.jwt?.jwksTimeoutMs).toBe(5000);
|
|
241
|
+
expect(config.oauth.jwt?.jwksCooldownMs).toBe(30_000);
|
|
242
|
+
expect(config.oauth.jwt?.jwksCacheMaxAgeMs).toBe(600_000);
|
|
243
|
+
expect(config.oauth.jwt?.maxTokenAgeSeconds).toBe(86_400);
|
|
244
|
+
expect(config.oauth.jwt?.clockToleranceSeconds).toBe(0);
|
|
245
245
|
expect(config.verify.maxBatchSize).toBe(50);
|
|
246
246
|
// The request limits (#118) travel the same path.
|
|
247
247
|
expect(config.verify.maxBodyBytes).toBe(65_536);
|
|
@@ -272,11 +272,11 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
|
|
|
272
272
|
const config = loadAppConfig(configDirPath, "development");
|
|
273
273
|
|
|
274
274
|
expect(config.http.port).toBe(8080);
|
|
275
|
-
expect(config.oauth.jwt
|
|
276
|
-
expect(config.oauth.jwt
|
|
277
|
-
expect(config.oauth.jwt
|
|
278
|
-
expect(config.oauth.jwt
|
|
279
|
-
expect(config.oauth.jwt
|
|
275
|
+
expect(config.oauth.jwt?.jwksTimeoutMs).toBe(2500);
|
|
276
|
+
expect(config.oauth.jwt?.jwksCooldownMs).toBe(0);
|
|
277
|
+
expect(config.oauth.jwt?.jwksCacheMaxAgeMs).toBe(120_000);
|
|
278
|
+
expect(config.oauth.jwt?.maxTokenAgeSeconds).toBe(600);
|
|
279
|
+
expect(config.oauth.jwt?.clockToleranceSeconds).toBe(60);
|
|
280
280
|
expect(config.verify.maxBatchSize).toBe(25);
|
|
281
281
|
expect(config.verify.maxBodyBytes).toBe(16_384);
|
|
282
282
|
expect(config.verify.maxResourceLength).toBe(128);
|
|
@@ -239,6 +239,34 @@ describe("installGracefulShutdown", () => {
|
|
|
239
239
|
expect(exitProcess).toHaveBeenCalledWith(3);
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
+
it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
|
|
243
|
+
// `exitCode` became 1 while `reason` still said "drained", so the one line
|
|
244
|
+
// an operator alerts on contradicted itself. The drain outcome is still
|
|
245
|
+
// carried, under its own key, so neither fact is lost.
|
|
246
|
+
const { signals, finishDraining, logger, exit } = install({
|
|
247
|
+
cleanup: () => Promise.reject(new Error("teardown failed")),
|
|
248
|
+
});
|
|
249
|
+
signals.get("SIGTERM")?.();
|
|
250
|
+
finishDraining();
|
|
251
|
+
await settle();
|
|
252
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
253
|
+
{ reason: "cleanup-failed", drain: "drained", exitCode: 1 },
|
|
254
|
+
"graceful shutdown: complete",
|
|
255
|
+
);
|
|
256
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("keeps reason and drain identical when cleanup succeeds", async () => {
|
|
260
|
+
const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() });
|
|
261
|
+
signals.get("SIGTERM")?.();
|
|
262
|
+
finishDraining();
|
|
263
|
+
await settle();
|
|
264
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
265
|
+
{ reason: "drained", drain: "drained", exitCode: 0 },
|
|
266
|
+
"graceful shutdown: complete",
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
242
270
|
it("removes its own signal listeners once shutting down", () => {
|
|
243
271
|
const { signals } = install();
|
|
244
272
|
signals.get("SIGTERM")?.();
|
|
@@ -61,7 +61,7 @@ export interface GracefulShutdownOptions {
|
|
|
61
61
|
* sequence — size both against the orchestrator's grace period, not one.
|
|
62
62
|
*/
|
|
63
63
|
readonly cleanupTimeoutMs?: number;
|
|
64
|
-
/** Injected in tests; defaults to
|
|
64
|
+
/** Injected in tests; defaults to {@link deferExit}. */
|
|
65
65
|
readonly exit?: (code: number) => void;
|
|
66
66
|
/** Injected in tests; defaults to `process.on`. */
|
|
67
67
|
readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
|
|
@@ -136,10 +136,16 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
|
|
|
136
136
|
if (finished) return;
|
|
137
137
|
finished = true;
|
|
138
138
|
let exitCode = code;
|
|
139
|
+
// `reason` names whatever decided the exit code, so the line an operator
|
|
140
|
+
// alerts on cannot say "drained" next to a non-zero code. The drain
|
|
141
|
+
// outcome keeps its own key rather than being overwritten — both facts
|
|
142
|
+
// are wanted, and a stable shape is what makes the line queryable.
|
|
143
|
+
let outcome = reason;
|
|
139
144
|
try {
|
|
140
145
|
if ((await runCleanup()) === CLEANUP_TIMED_OUT) {
|
|
141
146
|
logger.error({ cleanupTimeoutMs }, "graceful shutdown: cleanup timed out");
|
|
142
147
|
exitCode = 1;
|
|
148
|
+
outcome = "cleanup-timeout";
|
|
143
149
|
}
|
|
144
150
|
} catch (err) {
|
|
145
151
|
// Through the app logger, not `console.error`: a shutdown that
|
|
@@ -148,8 +154,9 @@ export function installGracefulShutdown(server: Server, options: GracefulShutdow
|
|
|
148
154
|
// drops.
|
|
149
155
|
logger.error({ err }, "graceful shutdown: cleanup failed");
|
|
150
156
|
exitCode = 1;
|
|
157
|
+
outcome = "cleanup-failed";
|
|
151
158
|
}
|
|
152
|
-
logger.info({ reason, exitCode }, "graceful shutdown: complete");
|
|
159
|
+
logger.info({ reason: outcome, drain: reason, exitCode }, "graceful shutdown: complete");
|
|
153
160
|
exit(exitCode);
|
|
154
161
|
};
|
|
155
162
|
|
package/templates/versions.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@o3co/auth.policy-verifier.core": "0.
|
|
3
|
-
"@o3co/auth.policy-verifier.builtins": "0.
|
|
4
|
-
"@o3co/auth.policy-verifier.server": "0.
|
|
2
|
+
"@o3co/auth.policy-verifier.core": "0.9.0",
|
|
3
|
+
"@o3co/auth.policy-verifier.builtins": "0.9.0",
|
|
4
|
+
"@o3co/auth.policy-verifier.server": "0.9.0"
|
|
5
5
|
}
|