@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-policy-verifier",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Scaffold a new o3co/auth.policy-verifier project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-policy-verifier-standalone",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.29.3",
@@ -89,12 +89,12 @@ describe("loadAppConfig", () => {
89
89
  pathPrefix: "",
90
90
  callerAuth: { header: "x-caller-token" },
91
91
  });
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");
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.mode).toBe("insecure-decode");
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.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);
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.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);
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 `process.exit`. */
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
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "@o3co/auth.policy-verifier.core": "0.8.0",
3
- "@o3co/auth.policy-verifier.builtins": "0.8.0",
4
- "@o3co/auth.policy-verifier.server": "0.8.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
  }