@apoa/core 0.1.2 → 0.2.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.js CHANGED
@@ -136,12 +136,14 @@ function parseScope(scope) {
136
136
  return scope.split(":");
137
137
  }
138
138
  function matchScope(pattern, requested) {
139
+ if (!pattern || !requested) return false;
139
140
  if (pattern === "*") return true;
140
141
  const patternParts = parseScope(pattern);
141
142
  const requestedParts = parseScope(requested);
142
143
  if (patternParts.length !== requestedParts.length) return false;
143
144
  for (let i = 0; i < patternParts.length; i++) {
144
145
  if (patternParts[i] === "*") continue;
146
+ if (!patternParts[i] || !requestedParts[i]) return false;
145
147
  if (patternParts[i] !== requestedParts[i]) return false;
146
148
  }
147
149
  return true;
@@ -307,8 +309,8 @@ async function authorize(token, service, action, options) {
307
309
  for (const rule of rules) {
308
310
  if (rule.enforcement === "hard") {
309
311
  const ruleKey = rule.id.startsWith("no-") ? rule.id.slice(3) : rule.id;
310
- const actionLower = action.toLowerCase();
311
- if (actionLower.includes(ruleKey.toLowerCase())) {
312
+ const actionSegments = action.toLowerCase().split(":");
313
+ if (actionSegments.includes(ruleKey.toLowerCase())) {
312
314
  return {
313
315
  authorized: false,
314
316
  reason: `hard rule '${rule.id}' violated`,
@@ -416,6 +418,13 @@ function validateDefinition(raw) {
416
418
  }
417
419
  if (!svc.scopes || !Array.isArray(svc.scopes) || svc.scopes.length === 0) {
418
420
  errors.push(`services[${i}].scopes must be a non-empty array`);
421
+ } else {
422
+ for (let j = 0; j < svc.scopes.length; j++) {
423
+ const s = svc.scopes[j];
424
+ if (typeof s !== "string" || s.length === 0) {
425
+ errors.push(`services[${i}].scopes[${j}] must be a non-empty string`);
426
+ }
427
+ }
419
428
  }
420
429
  validateServiceAccessMode(svc, i, errors, warnings);
421
430
  }
@@ -466,6 +475,11 @@ function validateMetadata(metadata, errors) {
466
475
  }
467
476
  const record = metadata;
468
477
  for (const key of keys) {
478
+ if (key.startsWith("_")) {
479
+ errors.push(
480
+ `metadata key '${key}' uses reserved prefix '_' (reserved for SDK internal use)`
481
+ );
482
+ }
469
483
  const value = record[key];
470
484
  if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
471
485
  errors.push(
@@ -542,9 +556,7 @@ function validateLegalFramework(legal, errors) {
542
556
  }
543
557
 
544
558
  // src/revocation/revoke.ts
545
- var defaultStore = new MemoryRevocationStore();
546
559
  async function revoke(tokenId, options, store) {
547
- const s = store ?? defaultStore;
548
560
  const record = {
549
561
  tokenId,
550
562
  revokedAt: /* @__PURE__ */ new Date(),
@@ -552,19 +564,18 @@ async function revoke(tokenId, options, store) {
552
564
  reason: options.reason,
553
565
  cascaded: []
554
566
  };
555
- await s.add(record);
567
+ await store.add(record);
556
568
  return record;
557
569
  }
558
570
  async function isRevoked(tokenId, store) {
559
- const s = store ?? defaultStore;
560
- const record = await s.check(tokenId);
571
+ const record = await store.check(tokenId);
561
572
  return record !== null;
562
573
  }
563
574
 
564
575
  // src/audit/log.ts
565
- var defaultStore2 = new MemoryAuditStore();
576
+ var defaultStore = new MemoryAuditStore();
566
577
  async function logAction(tokenId, entry, store) {
567
- const s = store ?? defaultStore2;
578
+ const s = store ?? defaultStore;
568
579
  const fullEntry = {
569
580
  ...entry,
570
581
  tokenId,
@@ -574,13 +585,13 @@ async function logAction(tokenId, entry, store) {
574
585
  }
575
586
 
576
587
  // src/audit/trail.ts
577
- var defaultStore3 = new MemoryAuditStore();
588
+ var defaultStore2 = new MemoryAuditStore();
578
589
  async function getAuditTrail(tokenId, options, store) {
579
- const s = store ?? defaultStore3;
590
+ const s = store ?? defaultStore2;
580
591
  return s.query(tokenId, options);
581
592
  }
582
593
  async function getAuditTrailByService(service, options, store) {
583
- const s = store ?? defaultStore3;
594
+ const s = store ?? defaultStore2;
584
595
  return s.queryByService(service, options);
585
596
  }
586
597
 
@@ -610,7 +621,7 @@ async function verifySignature(token, key) {
610
621
  }
611
622
 
612
623
  // src/token/create.ts
613
- async function createToken(definition, options) {
624
+ async function createToken(definition, options, parentTokenId) {
614
625
  if (definition.metadata) {
615
626
  validateMetadata2(definition.metadata);
616
627
  }
@@ -626,7 +637,10 @@ async function createToken(definition, options) {
626
637
  exp: Math.floor(
627
638
  (typeof definition.expires === "string" ? new Date(definition.expires) : definition.expires).getTime() / 1e3
628
639
  ),
629
- definition: serializeDefinition(definition)
640
+ definition: {
641
+ ...serializeDefinition(definition),
642
+ ...parentTokenId ? { parentToken: parentTokenId } : {}
643
+ }
630
644
  };
631
645
  if (definition.notBefore) {
632
646
  payload.nbf = Math.floor(
@@ -647,6 +661,7 @@ async function createToken(definition, options) {
647
661
  signature: raw.split(".")[2],
648
662
  issuer,
649
663
  audience,
664
+ parentToken: parentTokenId,
650
665
  raw
651
666
  };
652
667
  return token;
@@ -734,13 +749,29 @@ async function validateToken(token, options) {
734
749
  "No key provided: supply publicKey, keyResolver, or publicKeyResolver"
735
750
  );
736
751
  }
752
+ const permittedAlgorithms = options.algorithms ?? ["EdDSA", "ES256"];
753
+ let headerAlg;
754
+ try {
755
+ const header = decodeHeader(rawJwt);
756
+ headerAlg = typeof header.alg === "string" ? header.alg : void 0;
757
+ } catch {
758
+ }
759
+ if (headerAlg && !permittedAlgorithms.includes(headerAlg)) {
760
+ errors.push(
761
+ `Token alg '${headerAlg}' is not in the permitted list [${permittedAlgorithms.join(", ")}]`
762
+ );
763
+ }
737
764
  let payload;
738
- if (publicKey) {
765
+ let signatureVerified = false;
766
+ if (publicKey && headerAlg && permittedAlgorithms.includes(headerAlg)) {
739
767
  try {
740
768
  payload = await verifySignature(rawJwt, publicKey);
769
+ signatureVerified = true;
741
770
  } catch {
742
771
  errors.push("Signature verification failed");
743
772
  }
773
+ } else if (publicKey && !headerAlg) {
774
+ errors.push("Token header missing alg");
744
775
  }
745
776
  if (!payload) {
746
777
  try {
@@ -756,10 +787,12 @@ async function validateToken(token, options) {
756
787
  if (!payload) {
757
788
  return { valid: false, errors: errors.length > 0 ? errors : ["Unable to decode token"], warnings };
758
789
  }
759
- try {
760
- parsedToken = payloadToToken(payload, rawJwt);
761
- } catch {
762
- errors.push("Token payload has invalid structure");
790
+ if (signatureVerified) {
791
+ try {
792
+ parsedToken = payloadToToken(payload, rawJwt);
793
+ } catch {
794
+ errors.push("Token payload has invalid structure");
795
+ }
763
796
  }
764
797
  const clockSkew = options.clockSkew;
765
798
  if (payload.exp !== void 0) {
@@ -823,9 +856,7 @@ function base64urlDecode(input) {
823
856
  }
824
857
 
825
858
  // src/revocation/cascade.ts
826
- var defaultStore4 = new MemoryRevocationStore();
827
859
  async function cascadeRevoke(parentTokenId, childTokenIds, options, store) {
828
- const s = store ?? defaultStore4;
829
860
  const revokedAt = /* @__PURE__ */ new Date();
830
861
  for (const childId of childTokenIds) {
831
862
  const childRecord = {
@@ -835,7 +866,7 @@ async function cascadeRevoke(parentTokenId, childTokenIds, options, store) {
835
866
  reason: options.reason ? `Cascade: ${options.reason}` : `Cascade revocation from parent ${parentTokenId}`,
836
867
  cascaded: []
837
868
  };
838
- await s.add(childRecord);
869
+ await store.add(childRecord);
839
870
  }
840
871
  const parentRecord = {
841
872
  tokenId: parentTokenId,
@@ -844,7 +875,7 @@ async function cascadeRevoke(parentTokenId, childTokenIds, options, store) {
844
875
  reason: options.reason,
845
876
  cascaded: childTokenIds
846
877
  };
847
- await s.add(parentRecord);
878
+ await store.add(parentRecord);
848
879
  return parentRecord;
849
880
  }
850
881
 
@@ -893,7 +924,12 @@ function verifyAttenuation(parent, child, currentDepth = 0) {
893
924
  verifyConstraintsNotRelaxed(parentService, childService);
894
925
  }
895
926
  if (parentDef.rules && parentDef.rules.length > 0) {
896
- verifyRulesNotRemoved(parentDef.rules.map((r) => r.id), child);
927
+ verifyRulesNotRemoved(
928
+ parentDef.rules.map((r) => r.id),
929
+ child,
930
+ parentDef.services.flatMap((s) => s.scopes),
931
+ child.services.flatMap((s) => s.scopes)
932
+ );
897
933
  }
898
934
  }
899
935
  function verifyScopeSubset(parent, child) {
@@ -915,26 +951,23 @@ function verifyConstraintsNotRelaxed(parent, child) {
915
951
  for (const [key, parentValue] of Object.entries(parent.constraints)) {
916
952
  if (parentValue === false) {
917
953
  const childValue = child.constraints?.[key];
918
- if (childValue === true || childValue === void 0) {
919
- if (childValue === true) {
920
- throw new AttenuationViolationError(
921
- `Child relaxes constraint '${key}' on service '${child.service}' (parent: false, child: true)`,
922
- parent.scopes,
923
- child.scopes
924
- );
925
- }
926
- }
954
+ if (childValue === false) continue;
955
+ throw new AttenuationViolationError(
956
+ childValue === true ? `Child relaxes constraint '${key}' on service '${child.service}' (parent: false, child: true)` : `Child omits constraint '${key}' on service '${child.service}' (parent: false, child: undefined)`,
957
+ parent.scopes,
958
+ child.scopes
959
+ );
927
960
  }
928
961
  }
929
962
  }
930
- function verifyRulesNotRemoved(parentRuleIds, child) {
963
+ function verifyRulesNotRemoved(parentRuleIds, child, parentScopes, childScopes) {
931
964
  const childRuleIds = new Set(child.rules?.map((r) => r.id) ?? []);
932
965
  for (const parentRuleId of parentRuleIds) {
933
966
  if (!childRuleIds.has(parentRuleId)) {
934
967
  throw new AttenuationViolationError(
935
968
  `Child removes parent rule '${parentRuleId}'. Rules can only be added, not removed.`,
936
- [],
937
- []
969
+ parentScopes,
970
+ childScopes
938
971
  );
939
972
  }
940
973
  }
@@ -943,7 +976,6 @@ function verifyRulesNotRemoved(parentRuleIds, child) {
943
976
  // src/delegation/chain.ts
944
977
  async function delegate(parentToken, childDef, options) {
945
978
  const currentDepth = countDepth(parentToken);
946
- verifyAttenuation(parentToken, childDef, currentDepth);
947
979
  const parentDef = parentToken.definition;
948
980
  const parentRules = parentDef.rules ?? [];
949
981
  const childExtraRules = (childDef.rules ?? []).filter(
@@ -955,12 +987,32 @@ async function delegate(parentToken, childDef, options) {
955
987
  ...childDef.metadata,
956
988
  _delegationDepth: childDepth
957
989
  };
990
+ const inheritedServices = childDef.services.map((childSvc) => {
991
+ const parentSvc = parentDef.services.find((s) => s.service === childSvc.service);
992
+ if (!parentSvc?.constraints) return childSvc;
993
+ const inherited = {};
994
+ for (const [key, value] of Object.entries(parentSvc.constraints)) {
995
+ if (value === false) {
996
+ inherited[key] = false;
997
+ }
998
+ }
999
+ if (Object.keys(inherited).length === 0) return childSvc;
1000
+ return {
1001
+ ...childSvc,
1002
+ constraints: { ...inherited, ...childSvc.constraints }
1003
+ };
1004
+ });
1005
+ verifyAttenuation(
1006
+ parentToken,
1007
+ { ...childDef, services: inheritedServices, rules: mergedRules },
1008
+ currentDepth
1009
+ );
958
1010
  const fullDefinition = {
959
1011
  principal: parentDef.principal,
960
1012
  // Inherited — cannot be overridden
961
1013
  agent: childDef.agent,
962
1014
  agentProvider: parentDef.agentProvider,
963
- services: childDef.services,
1015
+ services: inheritedServices,
964
1016
  rules: mergedRules.length > 0 ? mergedRules : void 0,
965
1017
  expires: childDef.expires ?? parentDef.expires,
966
1018
  revocable: parentDef.revocable,
@@ -969,8 +1021,7 @@ async function delegate(parentToken, childDef, options) {
969
1021
  metadata: childMetadata,
970
1022
  legal: parentDef.legal
971
1023
  };
972
- const childToken = await createToken(fullDefinition, options);
973
- childToken.parentToken = parentToken.jti;
1024
+ const childToken = await createToken(fullDefinition, options, parentToken.jti);
974
1025
  return childToken;
975
1026
  }
976
1027
  function countDepth(token) {
@@ -1064,6 +1115,18 @@ function checkAttenuation(parent, child, index, errors) {
1064
1115
  );
1065
1116
  }
1066
1117
  }
1118
+ if (parentService.constraints) {
1119
+ for (const [key, parentValue] of Object.entries(parentService.constraints)) {
1120
+ if (parentValue === false) {
1121
+ const childValue = childService.constraints?.[key];
1122
+ if (childValue !== false) {
1123
+ errors.push(
1124
+ `Chain link ${index}: constraint '${key}' on '${childService.service}' relaxed by child (parent: false, child: ${childValue === void 0 ? "undefined" : JSON.stringify(childValue)})`
1125
+ );
1126
+ }
1127
+ }
1128
+ }
1129
+ }
1067
1130
  }
1068
1131
  const childExp = child.definition.expires instanceof Date ? child.definition.expires.getTime() : new Date(child.definition.expires).getTime();
1069
1132
  const parentExp = parent.definition.expires instanceof Date ? parent.definition.expires.getTime() : new Date(parent.definition.expires).getTime();
@@ -1074,6 +1137,82 @@ function checkAttenuation(parent, child, index, errors) {
1074
1137
  }
1075
1138
  }
1076
1139
 
1140
+ // src/jwks/index.ts
1141
+ import * as jose3 from "jose";
1142
+ async function publicKeyToJWK(publicKey, options) {
1143
+ const exported = await jose3.exportJWK(publicKey);
1144
+ const alg = options.alg ?? defaultAlgorithm(exported);
1145
+ return {
1146
+ ...exported,
1147
+ kid: options.kid,
1148
+ use: options.use ?? "sig",
1149
+ alg
1150
+ };
1151
+ }
1152
+ function buildJWKS(keys) {
1153
+ return { keys };
1154
+ }
1155
+ function createJWKSResolver(url, options = {}) {
1156
+ if (!options.fetch && !options.allowInsecure && !url.startsWith("https://")) {
1157
+ throw new Error(
1158
+ `JWKS URL must use https:// (got: ${JSON.stringify(url)}). Pass allowInsecure: true or a custom fetch for local development.`
1159
+ );
1160
+ }
1161
+ const cacheMaxAgeMs = options.cacheMaxAgeMs ?? 60 * 60 * 1e3;
1162
+ const cooldownMs = options.cooldownMs ?? 24 * 60 * 60 * 1e3;
1163
+ const fetchImpl = options.fetch ?? fetch;
1164
+ let cache = null;
1165
+ let inflight = null;
1166
+ async function fetchJWKS() {
1167
+ const response = await fetchImpl(url, {
1168
+ headers: { accept: "application/jwk-set+json, application/json" }
1169
+ });
1170
+ if (!response.ok) {
1171
+ throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);
1172
+ }
1173
+ const body = await response.json();
1174
+ if (!body || !Array.isArray(body.keys)) {
1175
+ throw new Error("JWKS response did not contain a `keys` array");
1176
+ }
1177
+ return body;
1178
+ }
1179
+ async function getJWKS() {
1180
+ const now = Date.now();
1181
+ if (cache && now - cache.fetchedAt < cacheMaxAgeMs) {
1182
+ return cache.jwks;
1183
+ }
1184
+ if (inflight) {
1185
+ return inflight;
1186
+ }
1187
+ inflight = fetchJWKS().then((jwks) => {
1188
+ cache = { fetchedAt: Date.now(), jwks };
1189
+ return jwks;
1190
+ }).catch((err) => {
1191
+ if (cache && now - cache.fetchedAt < cooldownMs) {
1192
+ return cache.jwks;
1193
+ }
1194
+ throw err;
1195
+ }).finally(() => {
1196
+ inflight = null;
1197
+ });
1198
+ return inflight;
1199
+ }
1200
+ return {
1201
+ async resolve(kid) {
1202
+ const jwks = await getJWKS();
1203
+ const match = jwks.keys.find((k) => k.kid === kid);
1204
+ if (!match) return null;
1205
+ const key = await jose3.importJWK(match, match.alg);
1206
+ return key;
1207
+ }
1208
+ };
1209
+ }
1210
+ function defaultAlgorithm(jwk) {
1211
+ if (jwk.kty === "OKP" && jwk.crv === "Ed25519") return "EdDSA";
1212
+ if (jwk.kty === "EC" && jwk.crv === "P-256") return "ES256";
1213
+ return jwk.alg ?? "EdDSA";
1214
+ }
1215
+
1077
1216
  // src/client.ts
1078
1217
  function createClient(options) {
1079
1218
  const revocationStore = options?.revocationStore ?? new MemoryRevocationStore();
@@ -1156,10 +1295,12 @@ export {
1156
1295
  ScopeViolationError,
1157
1296
  TokenExpiredError,
1158
1297
  authorize,
1298
+ buildJWKS,
1159
1299
  cascadeRevoke,
1160
1300
  checkConstraint,
1161
1301
  checkScope,
1162
1302
  createClient,
1303
+ createJWKSResolver,
1163
1304
  createToken,
1164
1305
  decodeHeader,
1165
1306
  delegate,
@@ -1173,6 +1314,7 @@ export {
1173
1314
  matchScope,
1174
1315
  parseDefinition,
1175
1316
  parseScope,
1317
+ publicKeyToJWK,
1176
1318
  revoke,
1177
1319
  sign,
1178
1320
  signToken,