@o3co/auth.policy-verifier.builtins 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/README.md CHANGED
@@ -16,14 +16,32 @@ All collectors implement `AttributeCollector`.
16
16
 
17
17
  | Name | Reads from | Emits | Constructor args |
18
18
  | --- | --- | --- | --- |
19
- | `PayloadScopeCollector` | `subject.scope` (space-separated string) | `ATTR_SCOPES: string[]` | none |
19
+ | `PayloadScopeCollector` | the scope claim — `subject.scope` by default, a space-separated string or an array of strings | `ATTR_SCOPES: string[]` | `{ claim?: string }` (#219: `"scp"` for Okta, `"permissions"` for Auth0) |
20
20
  | `PayloadSubjectIdCollector` | `subject.sub`, `subject.azp` | `ATTR_USER_ID`, `ATTR_CLIENT_ID` | none |
21
21
  | `StaticPermissionCollector` | — | `ATTR_PERMISSIONS: string[]` | `{ permissions: string[] }` |
22
22
  | `StaticRoleCollector` | — | `ATTR_ROLES: Role[]` | `{ roles: Role[] }` |
23
23
  | `RequestContextAttributeCollector` | declared fields of `requestContext` | the operator's own keys | `{ attributes: Mapping[] }` |
24
+ | `PayloadClaimAttributeCollector` | declared claims of the verified `subject` | the operator's own keys, or core's five | `{ attributes: Mapping[] }` (#219) |
24
25
 
25
26
  `StaticPermissionCollector` and `StaticRoleCollector` always emit the values supplied at construction time, regardless of request context.
26
27
 
28
+ ### PayloadClaimAttributeCollector
29
+
30
+ Promotes declared claims of the **verified subject** into attributes (#219) — the operator-declared way to turn an external IdP's claims into what the rules read, without a bespoke collector:
31
+
32
+ ```hocon
33
+ { collector = "PayloadClaimAttributeCollector"
34
+ attributes = [
35
+ { from = "o.rol", to = "roles", type = "string[]" } # Clerk: org role, by dot path
36
+ { from = "https://example.com/roles", to = "roles", type = "string[]" } # Auth0: a namespaced claim is one key, not a path
37
+ { from = "tid", to = "tenantId" }
38
+ ] }
39
+ ```
40
+
41
+ Same mapping shape as `RequestContextAttributeCollector` (`{ from, to?, type? }`, an exact key winning over a dot path, own properties only). What differs is the source, and therefore the trust: the subject bag is what the authenticator verified, so a mapping **may** land on core's five keys — `scopes`, `permissions`, `roles`, `userId`, `clientId` — where the request-context collector refuses them. Two collectors writing one list key union it; that is the deployment composing two issuer-derived sources, and it says so in config. A *scalar* key written by two collectors with different values throws `AttributeConflictError` and denies every request — do not map onto `userId` / `clientId` while `PayloadSubjectIdCollector` also writes them. Keys another package reserved (cedar's `request*`) stay refused, because they are derived from the request, not from the subject. And map only claims the IdP populates from its own registration or admin data: a claim minted from user-editable metadata (Clerk's `unsafe_metadata`, Auth0's `user_metadata`) is signed, not trusted.
42
+
43
+ For the scope claim specifically, prefer `PayloadScopeCollector { claim = "scp" }`, which also reads the space-delimited string form and pairs with `ResourceActionScopeRuleCollector { claim = "scp" }` so both agree about which tokens are scopeless.
44
+
27
45
  ### RequestContextAttributeCollector
28
46
 
29
47
  Promotes declared fields of `CollectorContext.requestContext` into attributes:
@@ -36,7 +54,7 @@ Promotes declared fields of `CollectorContext.requestContext` into attributes:
36
54
  ] }
37
55
  ```
38
56
 
39
- Each mapping is `{ from: string; to?: string; type?: "string" | "number" | "boolean" | "string[]" }`, `type` defaulting to `"string"`. A malformed mapping throws at construction; an unusable *value* does not — `requestContext` is caller-supplied request data, so a field that is missing, empty, or not of its declared type is simply not promoted.
57
+ Each mapping is `{ from: string; to?: string; type?: "string" | "number" | "boolean" | "string[]" }`, `type` defaulting to `"string"`. An exact key on `context` wins over walking a dot path: a literal `"tenant.id"` field is read before `tenant` → `id`. A malformed mapping throws at construction; an unusable *value* does not — `requestContext` is caller-supplied request data, so a field that is missing, empty, or not of its declared type is simply not promoted.
40
58
 
41
59
  The declaration is the trust boundary — this collector is the ready-made way to stay on the right side of the one described in [docs/extending.md](../../docs/extending.md#the-trust-boundary-requestcontext-is-the-callers). `requestContext` is free-form and unvalidated, so **nothing undeclared becomes an attribute** and a configured dot path traverses own properties only (`constructor.name` reads nothing). This collector invents no vocabulary of its own: the operator names both the fields and the keys, which is what keeps [AGENTS.md — Core Vocabulary Scope](../../AGENTS.md#core-vocabulary-scope) intact while still shipping something usable. For anything beyond read-check-write — deriving a value, calling out to a store — write a focused project-side `AttributeCollector` as that section describes.
42
60
 
@@ -285,11 +303,12 @@ import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
285
303
 
286
304
  | Registry | Name | Factory |
287
305
  | --- | --- | --- |
288
- | `attributeCollector` | `"PayloadScopeCollector"` | `() => new PayloadScopeCollector()` |
306
+ | `attributeCollector` | `"PayloadScopeCollector"` | `(config) => new PayloadScopeCollector(config)` |
289
307
  | `attributeCollector` | `"PayloadSubjectIdCollector"` | `() => new PayloadSubjectIdCollector()` |
290
308
  | `attributeCollector` | `"StaticPermissionCollector"` | `(config) => new StaticPermissionCollector(config)` |
291
309
  | `attributeCollector` | `"StaticRoleCollector"` | `(config) => new StaticRoleCollector(config)` |
292
310
  | `attributeCollector` | `"RequestContextAttributeCollector"` | `(config) => new RequestContextAttributeCollector(config)` |
311
+ | `attributeCollector` | `"PayloadClaimAttributeCollector"` | `(config) => new PayloadClaimAttributeCollector(config)` |
293
312
  | `ruleCollector` | `"ResourceActionScopeRuleCollector"` | `(config) => new ResourceActionScopeRuleCollector(config)` |
294
313
  | `ruleCollector` | `"ResourceActionPermissionRuleCollector"` | `() => new ResourceActionPermissionRuleCollector()` |
295
314
  | `resourceParser` | `"DotNotationResourceParser"` | `() => new DotNotationResourceParser()` |
@@ -0,0 +1,58 @@
1
+ import type { AttributeCollector, Attributes, CollectorContext } from "@o3co/auth.policy-verifier.core";
2
+ import { type AttributeMapping, type AttributeMappingCollectorConfig, type AttributeMappingType } from "./_attributeMapping.mjs";
3
+ /** One claim this collector promotes out of the verified subject bag. */
4
+ export type PayloadClaimAttributeMapping = AttributeMapping;
5
+ /** Types a claim may be promoted as. */
6
+ export type PayloadClaimAttributeType = AttributeMappingType;
7
+ /** Config entry accepted by `PayloadClaimAttributeCollector`. */
8
+ export type PayloadClaimAttributeCollectorConfig = AttributeMappingCollectorConfig;
9
+ /**
10
+ * Promotes declared claims of the verified subject into attributes (#219).
11
+ *
12
+ * The builtins read three claims — `scope`, `sub`, `azp` — and an external
13
+ * IdP puts what a rule needs elsewhere: Clerk's org role under `o.rol`,
14
+ * Auth0's roles under a namespaced `https://example.com/roles`, Okta's groups
15
+ * under `groups`. Until now every one of those meant a bespoke collector,
16
+ * even for the common case of "read this claim, check its type, write it
17
+ * under this key". This is the same declaration `RequestContextAttributeCollector`
18
+ * takes, pointed at `CollectorContext.subject` instead of the caller's body:
19
+ *
20
+ * ```hocon
21
+ * { collector = "PayloadClaimAttributeCollector"
22
+ * attributes = [
23
+ * { from = "o.rol", to = "roles", type = "string[]" }
24
+ * { from = "https://example.com/roles", to = "roles", type = "string[]" }
25
+ * { from = "tid", to = "tenantId" }
26
+ * ] }
27
+ * ```
28
+ *
29
+ * ## Core's vocabulary is a valid destination here
30
+ *
31
+ * `RequestContextAttributeCollector` refuses to write `scopes`, `permissions`,
32
+ * `roles`, `userId` and `clientId`, because its source is the request body
33
+ * and those keys are what the engine decides from. This collector's source is
34
+ * the signature-verified token — the same trust `PayloadScopeCollector` writes
35
+ * `scopes` from — so a verified claim may land on them: `permissions` from
36
+ * Auth0's `permissions`, `roles` from Clerk's `o.rol`, `scopes` from Okta's
37
+ * `scp`. Two collectors writing one list key **union** it; that is the
38
+ * deployment composing two issuer-derived sources, and it says so in config.
39
+ *
40
+ * Keys another package reserved — cedar's `request*` — stay refused: those
41
+ * are derived from the parsed request, not from the subject, and a claim
42
+ * landing on one would be a second writer with a different meaning.
43
+ *
44
+ * Two cautions the trust argument depends on. A signed claim is only as
45
+ * trustworthy as what the IdP put in it: map claims the IdP populates from
46
+ * its own registration or admin data, never from user-editable profile
47
+ * metadata (Clerk's `unsafe_metadata`, Auth0's `user_metadata`) — a mapping
48
+ * from those hands the end user their own roles. And the union rule is for
49
+ * lists: a *scalar* key written by two collectors with different values
50
+ * throws `AttributeConflictError` and denies every request, so do not map
51
+ * onto `userId` / `clientId` while `PayloadSubjectIdCollector` writes them.
52
+ */
53
+ export declare class PayloadClaimAttributeCollector implements AttributeCollector {
54
+ private readonly mappings;
55
+ constructor(config: PayloadClaimAttributeCollectorConfig);
56
+ collect(context: CollectorContext): Promise<Attributes>;
57
+ }
58
+ //# sourceMappingURL=PayloadClaimAttributeCollector.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PayloadClaimAttributeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/PayloadClaimAttributeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAElB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAKzC,OAAO,EACN,KAAK,gBAAgB,EACrB,KAAK,+BAA+B,EACpC,KAAK,oBAAoB,EAIzB,MAAM,yBAAyB,CAAC;AAEjC,yEAAyE;AACzE,MAAM,MAAM,4BAA4B,GAAG,gBAAgB,CAAC;AAC5D,wCAAwC;AACxC,MAAM,MAAM,yBAAyB,GAAG,oBAAoB,CAAC;AAC7D,iEAAiE;AACjE,MAAM,MAAM,oCAAoC,GAAG,+BAA+B,CAAC;AAEnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,qBAAa,8BAA+B,YAAW,kBAAkB;IACxE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6B;gBAE1C,MAAM,EAAE,oCAAoC;IASlD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAK7D"}
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { CORE_ATTRIBUTE_KEY_OWNER, suggestUnreservedAttributeKey, } from "@o3co/auth.policy-verifier.core";
4
+ import { parseAttributeMappings, promoteMappings, } from "./_attributeMapping.mjs";
5
+ /**
6
+ * Promotes declared claims of the verified subject into attributes (#219).
7
+ *
8
+ * The builtins read three claims — `scope`, `sub`, `azp` — and an external
9
+ * IdP puts what a rule needs elsewhere: Clerk's org role under `o.rol`,
10
+ * Auth0's roles under a namespaced `https://example.com/roles`, Okta's groups
11
+ * under `groups`. Until now every one of those meant a bespoke collector,
12
+ * even for the common case of "read this claim, check its type, write it
13
+ * under this key". This is the same declaration `RequestContextAttributeCollector`
14
+ * takes, pointed at `CollectorContext.subject` instead of the caller's body:
15
+ *
16
+ * ```hocon
17
+ * { collector = "PayloadClaimAttributeCollector"
18
+ * attributes = [
19
+ * { from = "o.rol", to = "roles", type = "string[]" }
20
+ * { from = "https://example.com/roles", to = "roles", type = "string[]" }
21
+ * { from = "tid", to = "tenantId" }
22
+ * ] }
23
+ * ```
24
+ *
25
+ * ## Core's vocabulary is a valid destination here
26
+ *
27
+ * `RequestContextAttributeCollector` refuses to write `scopes`, `permissions`,
28
+ * `roles`, `userId` and `clientId`, because its source is the request body
29
+ * and those keys are what the engine decides from. This collector's source is
30
+ * the signature-verified token — the same trust `PayloadScopeCollector` writes
31
+ * `scopes` from — so a verified claim may land on them: `permissions` from
32
+ * Auth0's `permissions`, `roles` from Clerk's `o.rol`, `scopes` from Okta's
33
+ * `scp`. Two collectors writing one list key **union** it; that is the
34
+ * deployment composing two issuer-derived sources, and it says so in config.
35
+ *
36
+ * Keys another package reserved — cedar's `request*` — stay refused: those
37
+ * are derived from the parsed request, not from the subject, and a claim
38
+ * landing on one would be a second writer with a different meaning.
39
+ *
40
+ * Two cautions the trust argument depends on. A signed claim is only as
41
+ * trustworthy as what the IdP put in it: map claims the IdP populates from
42
+ * its own registration or admin data, never from user-editable profile
43
+ * metadata (Clerk's `unsafe_metadata`, Auth0's `user_metadata`) — a mapping
44
+ * from those hands the end user their own roles. And the union rule is for
45
+ * lists: a *scalar* key written by two collectors with different values
46
+ * throws `AttributeConflictError` and denies every request, so do not map
47
+ * onto `userId` / `clientId` while `PayloadSubjectIdCollector` writes them.
48
+ */
49
+ export class PayloadClaimAttributeCollector {
50
+ mappings;
51
+ constructor(config) {
52
+ this.mappings = parseAttributeMappings("PayloadClaimAttributeCollector", config, (index, reservation) => reservation.owner === CORE_ATTRIBUTE_KEY_OWNER ? undefined : refusal(index, reservation));
53
+ }
54
+ async collect(context) {
55
+ // The subject bag is what the authenticator verified; nothing here reads
56
+ // `requestContext`, so a caller cannot steer a mapping with the body.
57
+ return promoteMappings(this.mappings, context.subject);
58
+ }
59
+ }
60
+ /** Why a mapping onto another package's key is refused, naming that package. */
61
+ function refusal(index, reservation) {
62
+ const { key, owner, reason } = reservation;
63
+ return (`PayloadClaimAttributeCollector: attributes[${index}] maps onto the reserved attribute "${key}", ` +
64
+ `which belongs to ${owner}${reason === undefined ? "" : ` — ${reason}`}. ` +
65
+ "That package derives the key from the request, not from the subject, so a verified claim " +
66
+ "landing on it would be a second writer with a different meaning. " +
67
+ `Promote the claim under a key of your own (for example "${suggestUnreservedAttributeKey(key)}").`);
68
+ }
@@ -1,10 +1,22 @@
1
1
  import type { AttributeCollector, Attributes, CollectorContext } from "@o3co/auth.policy-verifier.core";
2
+ /** Config entry accepted by `PayloadScopeCollector`. */
3
+ export interface PayloadScopeCollectorConfig {
4
+ /**
5
+ * The claim the scopes are read from (#219). Defaults to `scope`, the
6
+ * space-delimited OAuth claim; `scp` for Okta, `permissions` for Auth0,
7
+ * both of which are arrays of strings. Either shape is read.
8
+ */
9
+ claim?: string;
10
+ }
2
11
  /**
3
- * Attribute collector that splits the OAuth2 `scope` claim (space-delimited)
4
- * into a `string[]` and exposes it under `ATTR_SCOPES`. Missing or non-string
5
- * `scope` yields an empty array.
12
+ * Attribute collector that reads the scope claim — a space-delimited string
13
+ * or an array of strings — into a `string[]` under `ATTR_SCOPES`. Missing or
14
+ * malformed yields an empty array, and so does a list carrying a non-string:
15
+ * a value that is not a scope list asserts no capability.
6
16
  */
7
17
  export declare class PayloadScopeCollector implements AttributeCollector {
18
+ private readonly claim;
19
+ constructor(config?: PayloadScopeCollectorConfig);
8
20
  collect(context: CollectorContext): Promise<Attributes>;
9
21
  }
10
22
  //# sourceMappingURL=PayloadScopeCollector.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PayloadScopeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/PayloadScopeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAClB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAGzC;;;;GAIG;AACH,qBAAa,qBAAsB,YAAW,kBAAkB;IACzD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAK7D"}
1
+ {"version":3,"file":"PayloadScopeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/PayloadScopeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAClB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAIzC,wDAAwD;AACxD,MAAM,WAAW,2BAA2B;IAC3C;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,qBAAa,qBAAsB,YAAW,kBAAkB;IAC/D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAEnB,MAAM,CAAC,EAAE,2BAA2B;IAI1C,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAG7D"}
@@ -1,15 +1,19 @@
1
1
  // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { ATTR_SCOPES } from "@o3co/auth.policy-verifier.core";
4
+ import { DEFAULT_SCOPE_CLAIM, resolveClaimName, scopesFrom } from "./_claims.mjs";
4
5
  /**
5
- * Attribute collector that splits the OAuth2 `scope` claim (space-delimited)
6
- * into a `string[]` and exposes it under `ATTR_SCOPES`. Missing or non-string
7
- * `scope` yields an empty array.
6
+ * Attribute collector that reads the scope claim — a space-delimited string
7
+ * or an array of strings — into a `string[]` under `ATTR_SCOPES`. Missing or
8
+ * malformed yields an empty array, and so does a list carrying a non-string:
9
+ * a value that is not a scope list asserts no capability.
8
10
  */
9
11
  export class PayloadScopeCollector {
12
+ claim;
13
+ constructor(config) {
14
+ this.claim = resolveClaimName("PayloadScopeCollector", config?.claim, DEFAULT_SCOPE_CLAIM);
15
+ }
10
16
  async collect(context) {
11
- const scope = context.subject.scope;
12
- const scopes = typeof scope === "string" && scope ? scope.split(" ").filter(Boolean) : [];
13
- return new Map([[ATTR_SCOPES, scopes]]);
17
+ return new Map([[ATTR_SCOPES, scopesFrom(context.subject[this.claim])]]);
14
18
  }
15
19
  }
@@ -1,26 +1,16 @@
1
1
  import type { AttributeCollector, Attributes, CollectorContext } from "@o3co/auth.policy-verifier.core";
2
+ import { type AttributeMapping, type AttributeMappingCollectorConfig, type AttributeMappingType } from "./_attributeMapping.mjs";
2
3
  /** Types a `requestContext` field may be promoted as. */
3
- export type RequestContextAttributeType = "string" | "number" | "boolean" | "string[]";
4
- /** One field this collector promotes out of `requestContext`. */
5
- export interface RequestContextAttributeMapping {
6
- /** Field to read, as a dot path (`"tenant.id"`) into `requestContext`. */
7
- from: string;
8
- /**
9
- * Attribute key to write. Defaults to `from`.
10
- *
11
- * May not name a reserved key — core's five, plus whatever the packages a
12
- * composition loaded reserved for themselves. See the class doc comment for
13
- * why the caller's body may not land on vocabulary a deployment writes.
14
- */
15
- to?: string;
16
- /** Expected type; a value of any other shape is not promoted. Defaults to `"string"`. */
17
- type?: RequestContextAttributeType;
18
- }
4
+ export type RequestContextAttributeType = AttributeMappingType;
5
+ /**
6
+ * One field this collector promotes out of `requestContext`. `to` may not
7
+ * name a reserved key — core's five, plus whatever the packages a composition
8
+ * loaded reserved for themselves. See the class doc comment for why the
9
+ * caller's body may not land on vocabulary a deployment writes.
10
+ */
11
+ export type RequestContextAttributeMapping = AttributeMapping;
19
12
  /** Config entry accepted by `RequestContextAttributeCollector`. */
20
- export interface RequestContextAttributeCollectorConfig {
21
- /** The fields to promote. Must declare at least one. */
22
- attributes: RequestContextAttributeMapping[];
23
- }
13
+ export type RequestContextAttributeCollectorConfig = AttributeMappingCollectorConfig;
24
14
  /**
25
15
  * Promotes declared fields of `CollectorContext.requestContext` into attributes.
26
16
  *
@@ -1 +1 @@
1
- {"version":3,"file":"RequestContextAttributeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/RequestContextAttributeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAElB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAQzC,yDAAyD;AACzD,MAAM,MAAM,2BAA2B,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AASvF,iEAAiE;AACjE,MAAM,WAAW,8BAA8B;IAC9C,0EAA0E;IAC1E,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yFAAyF;IACzF,IAAI,CAAC,EAAE,2BAA2B,CAAC;CACnC;AAED,mEAAmE;AACnE,MAAM,WAAW,sCAAsC;IACtD,wDAAwD;IACxD,UAAU,EAAE,8BAA8B,EAAE,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,qBAAa,gCAAiC,YAAW,kBAAkB;IAC1E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;gBAE1D,MAAM,EAAE,sCAAsC;IAoCpD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAgB7D"}
1
+ {"version":3,"file":"RequestContextAttributeCollector.d.mts","sourceRoot":"","sources":["../../src/collectors/RequestContextAttributeCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACX,kBAAkB,EAElB,UAAU,EACV,gBAAgB,EAChB,MAAM,iCAAiC,CAAC;AAMzC,OAAO,EACN,KAAK,gBAAgB,EACrB,KAAK,+BAA+B,EACpC,KAAK,oBAAoB,EAIzB,MAAM,yBAAyB,CAAC;AAEjC,yDAAyD;AACzD,MAAM,MAAM,2BAA2B,GAAG,oBAAoB,CAAC;AAE/D;;;;;GAKG;AACH,MAAM,MAAM,8BAA8B,GAAG,gBAAgB,CAAC;AAE9D,mEAAmE;AACnE,MAAM,MAAM,sCAAsC,GAAG,+BAA+B,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiEG;AACH,qBAAa,gCAAiC,YAAW,kBAAkB;IAC1E,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6B;gBAE1C,MAAM,EAAE,sCAAsC;IAOpD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;CAQ7D"}
@@ -1,12 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
- import { attributeKeyReservation, CORE_ATTRIBUTE_KEY_OWNER, readUntrustedRequestContext, suggestUnreservedAttributeKey, } from "@o3co/auth.policy-verifier.core";
4
- const ATTRIBUTE_TYPES = [
5
- "string",
6
- "number",
7
- "boolean",
8
- "string[]",
9
- ];
3
+ import { CORE_ATTRIBUTE_KEY_OWNER, readUntrustedRequestContext, suggestUnreservedAttributeKey, } from "@o3co/auth.policy-verifier.core";
4
+ import { parseAttributeMappings, promoteMappings, } from "./_attributeMapping.mjs";
10
5
  /**
11
6
  * Promotes declared fields of `CollectorContext.requestContext` into attributes.
12
7
  *
@@ -76,47 +71,19 @@ const ATTRIBUTE_TYPES = [
76
71
  export class RequestContextAttributeCollector {
77
72
  mappings;
78
73
  constructor(config) {
79
- const attributes = config?.attributes;
80
- if (!Array.isArray(attributes) || attributes.length === 0) {
81
- throw new Error("RequestContextAttributeCollector: attributes must be a non-empty array of mappings");
82
- }
83
- this.mappings = attributes.map((mapping, index) => {
84
- const { from, to, type = "string" } = mapping ?? {};
85
- if (typeof from !== "string" || from === "") {
86
- throw new Error(`RequestContextAttributeCollector: attributes[${index}].from must be a non-empty string`);
87
- }
88
- if (to !== undefined && (typeof to !== "string" || to === "")) {
89
- throw new Error(`RequestContextAttributeCollector: attributes[${index}].to must be a non-empty string`);
90
- }
91
- if (!ATTRIBUTE_TYPES.includes(type)) {
92
- throw new Error(`RequestContextAttributeCollector: attributes[${index}].type must be one of ${ATTRIBUTE_TYPES.join(", ")}, got "${type}"`);
93
- }
94
- // Checked on the resolved key, not on `to`: `to` defaults to `from`,
95
- // so `{ from = "scopes" }` reaches the reserved key without ever
96
- // spelling it out. See the class doc comment for why this is refused.
97
- const key = to ?? from;
98
- const reservation = attributeKeyReservation(key);
99
- if (reservation !== undefined) {
100
- throw new Error(refusal(index, reservation));
101
- }
102
- return { from, to: key, type };
103
- });
74
+ // Every reserved destination is refused here — the source is the caller's
75
+ // body. The declaration parsing is shared with the collector that reads
76
+ // verified claims (#219); the policy is this collector's own.
77
+ this.mappings = parseAttributeMappings("RequestContextAttributeCollector", config, refusal);
104
78
  }
105
79
  async collect(context) {
106
- const attrs = new Map();
107
80
  // The unwrap is the acknowledgement `UntrustedRequestContext` asks for:
108
81
  // everything below this line is the caller's own data, which is why the
109
82
  // mapping list — not the request — decides what becomes an attribute.
110
83
  const requestContext = readUntrustedRequestContext(context.requestContext);
111
84
  if (requestContext === undefined)
112
- return attrs;
113
- for (const mapping of this.mappings) {
114
- const raw = readPath(requestContext, mapping.from);
115
- if (matchesType(raw, mapping.type)) {
116
- attrs.set(mapping.to, Array.isArray(raw) ? [...raw] : raw);
117
- }
118
- }
119
- return attrs;
85
+ return new Map();
86
+ return promoteMappings(this.mappings, requestContext);
120
87
  }
121
88
  }
122
89
  /**
@@ -149,32 +116,3 @@ function refusal(index, reservation) {
149
116
  "the one the deployment was supposed to supply. ";
150
117
  return `${head}Promote the field under a key of your own (for example "${suggestUnreservedAttributeKey(key)}").`;
151
118
  }
152
- /**
153
- * Reads a dot path, traversing own properties only. `requestContext` is
154
- * caller-supplied, so inherited members (`constructor`, `toString`, …) must not
155
- * be reachable through a configured path.
156
- */
157
- function readPath(root, path) {
158
- let current = root;
159
- for (const segment of path.split(".")) {
160
- if (typeof current !== "object" || current === null)
161
- return undefined;
162
- if (!Object.hasOwn(current, segment))
163
- return undefined;
164
- current = current[segment];
165
- }
166
- return current;
167
- }
168
- /** Whether `value` is usable as the declared type. Empty strings count as absent. */
169
- function matchesType(value, type) {
170
- switch (type) {
171
- case "string":
172
- return typeof value === "string" && value !== "";
173
- case "number":
174
- return typeof value === "number" && Number.isFinite(value);
175
- case "boolean":
176
- return typeof value === "boolean";
177
- case "string[]":
178
- return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
179
- }
180
- }
@@ -0,0 +1,45 @@
1
+ import type { AttributeKeyReservation, Attributes } from "@o3co/auth.policy-verifier.core";
2
+ /** Types a field may be promoted as. */
3
+ export type AttributeMappingType = "string" | "number" | "boolean" | "string[]";
4
+ export declare const ATTRIBUTE_MAPPING_TYPES: readonly AttributeMappingType[];
5
+ /** One field to promote. */
6
+ export interface AttributeMapping {
7
+ /**
8
+ * Field to read. An exact key on the source wins; otherwise a dot path
9
+ * (`"tenant.id"`) is walked — so a claim whose *name* carries dots, such as
10
+ * Auth0's `https://example.com/roles`, is one key, not a path.
11
+ */
12
+ from: string;
13
+ /** Attribute key to write. Defaults to `from`. */
14
+ to?: string;
15
+ /** Expected type; a value of any other shape is not promoted. Defaults to `"string"`. */
16
+ type?: AttributeMappingType;
17
+ }
18
+ /** Config entry shape shared by the mapping collectors. */
19
+ export interface AttributeMappingCollectorConfig {
20
+ /** The fields to promote. Must declare at least one. */
21
+ attributes: AttributeMapping[];
22
+ }
23
+ export type ResolvedAttributeMapping = Required<AttributeMapping>;
24
+ /**
25
+ * What a collector decides about a mapping onto a reserved key: the refusal
26
+ * to throw, or `undefined` to allow it. Called only for reserved destinations.
27
+ */
28
+ export type ReservedKeyPolicy = (index: number, reservation: AttributeKeyReservation) => string | undefined;
29
+ /**
30
+ * Validates the declaration at construction — boot, not first request — so a
31
+ * deployment that wrote a malformed or refused mapping never serves a decision.
32
+ */
33
+ export declare function parseAttributeMappings(collector: string, config: AttributeMappingCollectorConfig | undefined, reservedKeyPolicy: ReservedKeyPolicy): ResolvedAttributeMapping[];
34
+ /**
35
+ * Reads a field: the exact own key when the source has one, else a dot path
36
+ * traversing own properties only. Both sources this serves can carry anything,
37
+ * so inherited members (`constructor`, `toString`, …) must not be reachable
38
+ * through a configured name.
39
+ */
40
+ export declare function readPath(root: Record<string, unknown>, path: string): unknown;
41
+ /** Whether `value` is usable as the declared type. Empty strings count as absent. */
42
+ export declare function matchesType(value: unknown, type: AttributeMappingType): boolean;
43
+ /** Promotes every declared field whose value matches its declared type. */
44
+ export declare function promoteMappings(mappings: readonly ResolvedAttributeMapping[], source: Record<string, unknown>): Attributes;
45
+ //# sourceMappingURL=_attributeMapping.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_attributeMapping.d.mts","sourceRoot":"","sources":["../../src/collectors/_attributeMapping.mts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,uBAAuB,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAG3F,wCAAwC;AACxC,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,UAAU,CAAC;AAEhF,eAAO,MAAM,uBAAuB,EAAE,SAAS,oBAAoB,EAKlE,CAAC;AAEF,4BAA4B;AAC5B,MAAM,WAAW,gBAAgB;IAChC;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,yFAAyF;IACzF,IAAI,CAAC,EAAE,oBAAoB,CAAC;CAC5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,+BAA+B;IAC/C,wDAAwD;IACxD,UAAU,EAAE,gBAAgB,EAAE,CAAC;CAC/B;AAED,MAAM,MAAM,wBAAwB,GAAG,QAAQ,CAAC,gBAAgB,CAAC,CAAC;AAElE;;;GAGG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAC/B,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,uBAAuB,KAChC,MAAM,GAAG,SAAS,CAAC;AAExB;;;GAGG;AACH,wBAAgB,sBAAsB,CACrC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,+BAA+B,GAAG,SAAS,EACnD,iBAAiB,EAAE,iBAAiB,GAClC,wBAAwB,EAAE,CA4B5B;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAS7E;AAED,qFAAqF;AACrF,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,oBAAoB,GAAG,OAAO,CAW/E;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,CAC9B,QAAQ,EAAE,SAAS,wBAAwB,EAAE,EAC7C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,UAAU,CASZ"}
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { attributeKeyReservation } from "@o3co/auth.policy-verifier.core";
4
+ export const ATTRIBUTE_MAPPING_TYPES = [
5
+ "string",
6
+ "number",
7
+ "boolean",
8
+ "string[]",
9
+ ];
10
+ /**
11
+ * Validates the declaration at construction — boot, not first request — so a
12
+ * deployment that wrote a malformed or refused mapping never serves a decision.
13
+ */
14
+ export function parseAttributeMappings(collector, config, reservedKeyPolicy) {
15
+ const attributes = config?.attributes;
16
+ if (!Array.isArray(attributes) || attributes.length === 0) {
17
+ throw new Error(`${collector}: attributes must be a non-empty array of mappings`);
18
+ }
19
+ return attributes.map((mapping, index) => {
20
+ const { from, to, type = "string" } = mapping ?? {};
21
+ if (typeof from !== "string" || from === "") {
22
+ throw new Error(`${collector}: attributes[${index}].from must be a non-empty string`);
23
+ }
24
+ if (to !== undefined && (typeof to !== "string" || to === "")) {
25
+ throw new Error(`${collector}: attributes[${index}].to must be a non-empty string`);
26
+ }
27
+ if (!ATTRIBUTE_MAPPING_TYPES.includes(type)) {
28
+ throw new Error(`${collector}: attributes[${index}].type must be one of ${ATTRIBUTE_MAPPING_TYPES.join(", ")}, got "${type}"`);
29
+ }
30
+ // Checked on the resolved key, not on `to`: `to` defaults to `from`,
31
+ // so `{ from = "scopes" }` reaches a reserved key without spelling it out.
32
+ const key = to ?? from;
33
+ const reservation = attributeKeyReservation(key);
34
+ if (reservation !== undefined) {
35
+ const refusal = reservedKeyPolicy(index, reservation);
36
+ if (refusal !== undefined)
37
+ throw new Error(refusal);
38
+ }
39
+ return { from, to: key, type };
40
+ });
41
+ }
42
+ /**
43
+ * Reads a field: the exact own key when the source has one, else a dot path
44
+ * traversing own properties only. Both sources this serves can carry anything,
45
+ * so inherited members (`constructor`, `toString`, …) must not be reachable
46
+ * through a configured name.
47
+ */
48
+ export function readPath(root, path) {
49
+ if (Object.hasOwn(root, path))
50
+ return root[path];
51
+ let current = root;
52
+ for (const segment of path.split(".")) {
53
+ if (typeof current !== "object" || current === null)
54
+ return undefined;
55
+ if (!Object.hasOwn(current, segment))
56
+ return undefined;
57
+ current = current[segment];
58
+ }
59
+ return current;
60
+ }
61
+ /** Whether `value` is usable as the declared type. Empty strings count as absent. */
62
+ export function matchesType(value, type) {
63
+ switch (type) {
64
+ case "string":
65
+ return typeof value === "string" && value !== "";
66
+ case "number":
67
+ return typeof value === "number" && Number.isFinite(value);
68
+ case "boolean":
69
+ return typeof value === "boolean";
70
+ case "string[]":
71
+ return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== "");
72
+ }
73
+ }
74
+ /** Promotes every declared field whose value matches its declared type. */
75
+ export function promoteMappings(mappings, source) {
76
+ const attrs = new Map();
77
+ for (const mapping of mappings) {
78
+ const raw = readPath(source, mapping.from);
79
+ if (matchesType(raw, mapping.type)) {
80
+ attrs.set(mapping.to, Array.isArray(raw) ? [...raw] : raw);
81
+ }
82
+ }
83
+ return attrs;
84
+ }
@@ -0,0 +1,20 @@
1
+ /** The claim RFC 8693 §4.2 / RFC 9068 §2.2.3 name, and the default. */
2
+ export declare const DEFAULT_SCOPE_CLAIM = "scope";
3
+ /**
4
+ * Resolves a `claim` option: absent means `fallback`, a non-empty string names
5
+ * the claim, anything else — an empty string, a number, an explicit `null` — is
6
+ * refused at construction. `null` is not "unset": a `null` in a config was
7
+ * produced rather than written (an unrendered template, a missing env var), and
8
+ * reading it as the default would silently point the collector elsewhere.
9
+ */
10
+ export declare function resolveClaimName(collector: string, raw: unknown, fallback: string): string;
11
+ /**
12
+ * Reads a scope claim as a list. The OAuth `scope` claim is a space-delimited
13
+ * string; Okta's `scp` and Auth0's `permissions` are arrays of strings. Either
14
+ * shape yields the non-empty entries. Anything else — absent, a number, a list
15
+ * carrying a non-string — asserts no capability at all, the way a non-string
16
+ * `scope` never did: fail closed rather than pick the strings out of a value
17
+ * that is not a scope list.
18
+ */
19
+ export declare function scopesFrom(value: unknown): string[];
20
+ //# sourceMappingURL=_claims.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"_claims.d.mts","sourceRoot":"","sources":["../../src/collectors/_claims.mts"],"names":[],"mappings":"AASA,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,UAAU,CAAC;AAE3C;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM1F;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAMnD"}
@@ -0,0 +1,40 @@
1
+ // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /*
4
+ * Which claim carries the scopes, and how its value is read (#219). Shared by
5
+ * the collector that promotes scopes and the rule collector that has to know
6
+ * whether a token asserted any, so the two cannot disagree about a token.
7
+ */
8
+ /** The claim RFC 8693 §4.2 / RFC 9068 §2.2.3 name, and the default. */
9
+ export const DEFAULT_SCOPE_CLAIM = "scope";
10
+ /**
11
+ * Resolves a `claim` option: absent means `fallback`, a non-empty string names
12
+ * the claim, anything else — an empty string, a number, an explicit `null` — is
13
+ * refused at construction. `null` is not "unset": a `null` in a config was
14
+ * produced rather than written (an unrendered template, a missing env var), and
15
+ * reading it as the default would silently point the collector elsewhere.
16
+ */
17
+ export function resolveClaimName(collector, raw, fallback) {
18
+ if (raw === undefined)
19
+ return fallback;
20
+ if (typeof raw !== "string" || raw === "") {
21
+ throw new Error(`${collector}: claim must be a non-empty string`);
22
+ }
23
+ return raw;
24
+ }
25
+ /**
26
+ * Reads a scope claim as a list. The OAuth `scope` claim is a space-delimited
27
+ * string; Okta's `scp` and Auth0's `permissions` are arrays of strings. Either
28
+ * shape yields the non-empty entries. Anything else — absent, a number, a list
29
+ * carrying a non-string — asserts no capability at all, the way a non-string
30
+ * `scope` never did: fail closed rather than pick the strings out of a value
31
+ * that is not a scope list.
32
+ */
33
+ export function scopesFrom(value) {
34
+ if (typeof value === "string")
35
+ return value.split(" ").filter(Boolean);
36
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
37
+ return value.filter(Boolean);
38
+ }
39
+ return [];
40
+ }
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
- export { PayloadScopeCollector } from "./collectors/PayloadScopeCollector.mjs";
1
+ export { PayloadClaimAttributeCollector, type PayloadClaimAttributeCollectorConfig, type PayloadClaimAttributeMapping, type PayloadClaimAttributeType, } from "./collectors/PayloadClaimAttributeCollector.mjs";
2
+ export { PayloadScopeCollector, type PayloadScopeCollectorConfig, } from "./collectors/PayloadScopeCollector.mjs";
2
3
  export { PayloadSubjectIdCollector } from "./collectors/PayloadSubjectIdCollector.mjs";
3
4
  export { RequestContextAttributeCollector, type RequestContextAttributeCollectorConfig, type RequestContextAttributeMapping, type RequestContextAttributeType, } from "./collectors/RequestContextAttributeCollector.mjs";
4
5
  export { StaticPermissionCollector } from "./collectors/StaticPermissionCollector.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAIA,OAAO,EAAE,qBAAqB,EAAE,MAAM,wCAAwC,CAAC;AAC/E,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EACN,gCAAgC,EAChC,KAAK,sCAAsC,EAC3C,KAAK,8BAA8B,EACnC,KAAK,2BAA2B,GAChC,MAAM,mDAAmD,CAAC;AAC3D,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAE3E,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EAAE,yBAAyB,EAAE,MAAM,0CAA0C,CAAC;AAErF,OAAO,EAAE,kBAAkB,EAAE,KAAK,wBAAwB,EAAE,MAAM,gCAAgC,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EACN,mBAAmB,EACnB,KAAK,yBAAyB,GAC9B,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,eAAe,EAAE,KAAK,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAC1F,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,OAAO,EAAE,qCAAqC,EAAE,MAAM,8DAA8D,CAAC;AACrH,OAAO,EACN,gCAAgC,EAChC,KAAK,sCAAsC,EAC3C,KAAK,eAAe,GACpB,MAAM,yDAAyD,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAIA,OAAO,EACN,8BAA8B,EAC9B,KAAK,oCAAoC,EACzC,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,GAC9B,MAAM,iDAAiD,CAAC;AACzD,OAAO,EACN,qBAAqB,EACrB,KAAK,2BAA2B,GAChC,MAAM,wCAAwC,CAAC;AAChD,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EACN,gCAAgC,EAChC,KAAK,sCAAsC,EAC3C,KAAK,8BAA8B,EACnC,KAAK,2BAA2B,GAChC,MAAM,mDAAmD,CAAC;AAC3D,OAAO,EAAE,yBAAyB,EAAE,MAAM,4CAA4C,CAAC;AACvF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAE3E,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAEvD,OAAO,EAAE,yBAAyB,EAAE,MAAM,0CAA0C,CAAC;AAErF,OAAO,EAAE,kBAAkB,EAAE,KAAK,wBAAwB,EAAE,MAAM,gCAAgC,CAAC;AACnG,OAAO,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EACN,mBAAmB,EACnB,KAAK,yBAAyB,GAC9B,MAAM,iCAAiC,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,eAAe,EAAE,KAAK,qBAAqB,EAAE,MAAM,6BAA6B,CAAC;AAC1F,OAAO,EAAE,aAAa,EAAE,KAAK,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,OAAO,EAAE,qCAAqC,EAAE,MAAM,8DAA8D,CAAC;AACrH,OAAO,EACN,gCAAgC,EAChC,KAAK,sCAAsC,EAC3C,KAAK,eAAe,GACpB,MAAM,yDAAyD,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,EAAE,QAAQ,EAAE,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // Collectors
4
- export { PayloadScopeCollector } from "./collectors/PayloadScopeCollector.mjs";
4
+ export { PayloadClaimAttributeCollector, } from "./collectors/PayloadClaimAttributeCollector.mjs";
5
+ export { PayloadScopeCollector, } from "./collectors/PayloadScopeCollector.mjs";
5
6
  export { PayloadSubjectIdCollector } from "./collectors/PayloadSubjectIdCollector.mjs";
6
7
  export { RequestContextAttributeCollector, } from "./collectors/RequestContextAttributeCollector.mjs";
7
8
  export { StaticPermissionCollector } from "./collectors/StaticPermissionCollector.mjs";
@@ -1 +1 @@
1
- {"version":3,"file":"module.d.mts","sourceRoot":"","sources":["../src/module.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAU9D;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,MAyCrC,CAAC"}
1
+ {"version":3,"file":"module.d.mts","sourceRoot":"","sources":["../src/module.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAW9D;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,MA6CrC,CAAC"}
package/dist/module.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ import { PayloadClaimAttributeCollector } from "./collectors/PayloadClaimAttributeCollector.mjs";
3
4
  import { PayloadScopeCollector } from "./collectors/PayloadScopeCollector.mjs";
4
5
  import { PayloadSubjectIdCollector } from "./collectors/PayloadSubjectIdCollector.mjs";
5
6
  import { RequestContextAttributeCollector } from "./collectors/RequestContextAttributeCollector.mjs";
@@ -17,11 +18,12 @@ export const builtinCollectorsModule = {
17
18
  name: "builtin-collectors",
18
19
  async init(context) {
19
20
  // Attribute collector factories
20
- context.attributeCollectorRegistry.register("PayloadScopeCollector", () => new PayloadScopeCollector());
21
+ context.attributeCollectorRegistry.register("PayloadScopeCollector", (config) => new PayloadScopeCollector(config));
21
22
  context.attributeCollectorRegistry.register("PayloadSubjectIdCollector", () => new PayloadSubjectIdCollector());
22
23
  context.attributeCollectorRegistry.register("StaticPermissionCollector", (config) => new StaticPermissionCollector(config));
23
24
  context.attributeCollectorRegistry.register("StaticRoleCollector", (config) => new StaticRoleCollector(config));
24
25
  context.attributeCollectorRegistry.register("RequestContextAttributeCollector", (config) => new RequestContextAttributeCollector(config));
26
+ context.attributeCollectorRegistry.register("PayloadClaimAttributeCollector", (config) => new PayloadClaimAttributeCollector(config));
25
27
  // Rule collector factories
26
28
  context.ruleCollectorRegistry.register("ResourceActionScopeRuleCollector", (config) => new ResourceActionScopeRuleCollector(config));
27
29
  context.ruleCollectorRegistry.register("ResourceActionPermissionRuleCollector", () => new ResourceActionPermissionRuleCollector());
@@ -1,5 +1,5 @@
1
1
  import type { CollectorContext, Rule, RuleCollector } from "@o3co/auth.policy-verifier.core";
2
- /** How the collector treats a token that carries no `scope` claim. */
2
+ /** How the collector treats a token that carries no scope claim (`scope`, or the configured `claim`). */
3
3
  export type ScopelessPolicy = "deny" | "skip";
4
4
  /** Config entry accepted by `ResourceActionScopeRuleCollector`. */
5
5
  export interface ResourceActionScopeRuleCollectorConfig {
@@ -15,6 +15,12 @@ export interface ResourceActionScopeRuleCollectorConfig {
15
15
  * `read:x`. See `HasScopeOptions.allowBareScopeRewrite`.
16
16
  */
17
17
  allowBareScopeRewrite?: boolean;
18
+ /**
19
+ * The claim whose presence says the token asserted scopes (#219). Defaults
20
+ * to `scope`; set it to what `PayloadScopeCollector` reads (`scp` for Okta)
21
+ * so the two agree about which tokens are scopeless.
22
+ */
23
+ claim?: string;
18
24
  }
19
25
  /**
20
26
  * Generates a HasScope rule derived from the request action and resource type.
@@ -56,6 +62,7 @@ export interface ResourceActionScopeRuleCollectorConfig {
56
62
  export declare class ResourceActionScopeRuleCollector implements RuleCollector {
57
63
  private readonly scopeless;
58
64
  private readonly allowBareScopeRewrite;
65
+ private readonly claim;
59
66
  constructor(config?: ResourceActionScopeRuleCollectorConfig);
60
67
  collect(context: CollectorContext): Promise<Rule[]>;
61
68
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ResourceActionScopeRuleCollector.d.mts","sourceRoot":"","sources":["../../../src/rules/collectors/ResourceActionScopeRuleCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAG7F,sEAAsE;AACtE,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,CAAC;AAI9C,mEAAmE;AACnE,MAAM,WAAW,sCAAsC;IACtD;;;;OAIG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,qBAAa,gCAAiC,YAAW,aAAa;IACrE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkB;IAC5C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAU;gBAEpC,MAAM,CAAC,EAAE,sCAAsC;IAqBrD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;CAOzD"}
1
+ {"version":3,"file":"ResourceActionScopeRuleCollector.d.mts","sourceRoot":"","sources":["../../../src/rules/collectors/ResourceActionScopeRuleCollector.mts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAI7F,yGAAyG;AACzG,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,MAAM,CAAC;AAI9C,mEAAmE;AACnE,MAAM,WAAW,sCAAsC;IACtD;;;;OAIG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,qBAAa,gCAAiC,YAAW,aAAa;IACrE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAkB;IAC5C,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAU;IAChD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAEnB,MAAM,CAAC,EAAE,sCAAsC;IA0BrD,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;CAOzD"}
@@ -1,5 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 1o1 Co. Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ import { DEFAULT_SCOPE_CLAIM, resolveClaimName } from "../../collectors/_claims.mjs";
3
4
  import { HasScope } from "../HasScope.mjs";
4
5
  const SCOPELESS_POLICIES = ["deny", "skip"];
5
6
  /**
@@ -42,7 +43,9 @@ const SCOPELESS_POLICIES = ["deny", "skip"];
42
43
  export class ResourceActionScopeRuleCollector {
43
44
  scopeless;
44
45
  allowBareScopeRewrite;
46
+ claim;
45
47
  constructor(config) {
48
+ this.claim = resolveClaimName("ResourceActionScopeRuleCollector", config?.claim, DEFAULT_SCOPE_CLAIM);
46
49
  const scopeless = config?.scopeless ?? "deny";
47
50
  if (!SCOPELESS_POLICIES.includes(scopeless)) {
48
51
  throw new Error(`ResourceActionScopeRuleCollector: scopeless must be one of ${SCOPELESS_POLICIES.join(", ")}, got "${scopeless}"`);
@@ -58,7 +61,7 @@ export class ResourceActionScopeRuleCollector {
58
61
  this.allowBareScopeRewrite = rawRewrite ?? false;
59
62
  }
60
63
  async collect(context) {
61
- if (this.scopeless === "skip" && context.subject.scope === undefined) {
64
+ if (this.scopeless === "skip" && context.subject[this.claim] === undefined) {
62
65
  return [];
63
66
  }
64
67
  const scope = `${context.action}:${context.resource.resourceType}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth.policy-verifier.builtins",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@o3co/auth.policy-verifier.core": "0.8.0"
34
+ "@o3co/auth.policy-verifier.core": "0.9.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^26.4.0",