@spfn/auth 0.2.0-beta.86 → 0.2.0-beta.88

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.
Files changed (50) hide show
  1. package/README.md +347 -27
  2. package/dist/{authenticate-DTA7W4v2.d.ts → authenticate-ZssfqJ7d.d.ts} +201 -5
  3. package/dist/client-proof.d.ts +185 -46
  4. package/dist/client-proof.js +253 -34
  5. package/dist/client-proof.js.map +1 -1
  6. package/dist/config.d.ts +36 -0
  7. package/dist/config.js +18 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/errors.d.ts +93 -2
  10. package/dist/errors.js +57 -0
  11. package/dist/errors.js.map +1 -1
  12. package/dist/index.d.ts +33 -3
  13. package/dist/index.js +60 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/nextjs/server.d.ts +2 -2
  16. package/dist/server.d.ts +148 -12
  17. package/dist/server.js +1410 -192
  18. package/dist/server.js.map +1 -1
  19. package/dist/{session-CGxgH3C9.d.ts → session-CFK4BT25.d.ts} +1 -1
  20. package/dist/{types-1BMx0OX1.d.ts → types-CD95yudz.d.ts} +15 -1
  21. package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
  22. package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
  23. package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
  24. package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
  25. package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
  26. package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
  27. package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
  28. package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
  29. package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
  30. package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
  31. package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
  32. package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
  33. package/package.json +6 -9
  34. package/migrations/meta/0000_snapshot.json +0 -1632
  35. package/migrations/meta/0001_snapshot.json +0 -1660
  36. package/migrations/meta/0002_snapshot.json +0 -1660
  37. package/migrations/meta/0003_snapshot.json +0 -1689
  38. package/migrations/meta/0004_snapshot.json +0 -1721
  39. package/migrations/meta/0005_snapshot.json +0 -1721
  40. package/migrations/meta/0006_snapshot.json +0 -1921
  41. package/migrations/meta/0007_snapshot.json +0 -1916
  42. package/migrations/meta/_journal.json +0 -62
  43. /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
  44. /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
  45. /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
  46. /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
  47. /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
  48. /package/migrations/{0005_lethal_lifeguard.sql → 20260629103209_lethal_lifeguard/migration.sql} +0 -0
  49. /package/migrations/{0006_easy_hardball.sql → 20260709073531_easy_hardball/migration.sql} +0 -0
  50. /package/migrations/{0007_glossy_major_mapleleaf.sql → 20260714081434_glossy_major_mapleleaf/migration.sql} +0 -0
@@ -345,7 +345,7 @@ function encodeString(value) {
345
345
  }
346
346
 
347
347
  // src/server/client-proof/proof.ts
348
- import { createHash, createHmac, timingSafeEqual } from "crypto";
348
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from "crypto";
349
349
  var CLIENT_PROOF_PROFILE = "clientProofV1";
350
350
  var ABSENT_BODY_SHA256 = "0".repeat(64);
351
351
  var DEFAULT_REPLAY_WINDOW_MILLIS = 3e5;
@@ -360,6 +360,10 @@ var PROOF_INPUT_FIELDS = [
360
360
  "bodySha256"
361
361
  ];
362
362
  var PROOF_INPUT_SEPARATOR = "\n";
363
+ var PROOF_SIGNATURE_BYTES = 64;
364
+ var PROOF_SIGNATURE_HEX_LENGTH = PROOF_SIGNATURE_BYTES * 2;
365
+ var PROOF_SIGNATURE_PATTERN = /^[0-9a-f]{128}$/;
366
+ var RAW_SIGNATURE_ENCODING = "ieee-p1363";
363
367
  var ProofInputError = class extends Error {
364
368
  constructor() {
365
369
  super("proof input field contains a C0 control character");
@@ -387,19 +391,43 @@ function canonicalProofInput(input) {
387
391
  }
388
392
  return fields.join(PROOF_INPUT_SEPARATOR);
389
393
  }
390
- function computeClientProof(input, key) {
391
- return createHmac("sha256", key).update(canonicalProofInput(input), "utf8").digest("hex");
392
- }
393
- function sha256Hex(bytes) {
394
- return createHash("sha256").update(bytes).digest("hex");
394
+ function parseClientProofPublicKey(spkiDerBase64) {
395
+ const key = createPublicKey({
396
+ key: Buffer.from(spkiDerBase64, "base64"),
397
+ format: "der",
398
+ type: "spki"
399
+ });
400
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
401
+ throw new Error("a clientProofV1 public key must be an ECDSA P-256 key");
402
+ }
403
+ return key;
395
404
  }
396
- function constantTimeEqualsProof(expected, presented) {
397
- const a = Buffer.from(expected, "utf8");
398
- const b = Buffer.from(presented, "utf8");
399
- if (a.length !== b.length) {
405
+ function verifyClientProof(input, presentedProof, publicKey) {
406
+ const data = Buffer.from(canonicalProofInput(input), "utf8");
407
+ if (!PROOF_SIGNATURE_PATTERN.test(presentedProof)) {
400
408
  return false;
401
409
  }
402
- return timingSafeEqual(a, b);
410
+ return verify(
411
+ "sha256",
412
+ data,
413
+ { key: publicKey, dsaEncoding: RAW_SIGNATURE_ENCODING },
414
+ Buffer.from(presentedProof, "hex")
415
+ );
416
+ }
417
+ function signClientProof(input, privateKeyPkcs8DerBase64) {
418
+ const key = createPrivateKey({
419
+ key: Buffer.from(privateKeyPkcs8DerBase64, "base64"),
420
+ format: "der",
421
+ type: "pkcs8"
422
+ });
423
+ return sign(
424
+ "sha256",
425
+ Buffer.from(canonicalProofInput(input), "utf8"),
426
+ { key, dsaEncoding: RAW_SIGNATURE_ENCODING }
427
+ ).toString("hex");
428
+ }
429
+ function sha256Hex(bytes) {
430
+ return createHash("sha256").update(bytes).digest("hex");
403
431
  }
404
432
 
405
433
  // src/server/client-proof/refusal.ts
@@ -490,6 +518,86 @@ function contractViolation(message) {
490
518
  return new ClientProofRefusal("CONTRACT_UNSUPPORTED", message);
491
519
  }
492
520
 
521
+ // src/server/client-proof/replay-store.ts
522
+ import { getCache } from "@spfn/core/cache";
523
+ function replayLedgerKey(clientId, nonce) {
524
+ return JSON.stringify([clientId, nonce]);
525
+ }
526
+ var MemoryReplayLedger = class {
527
+ /** replayLedgerKey(...) → the millis it was spent at. */
528
+ spent = /* @__PURE__ */ new Map();
529
+ isSpent(clientId, nonce) {
530
+ return this.spent.has(replayLedgerKey(clientId, nonce));
531
+ }
532
+ /** Records the pair at `atMillis`; false when it was already spent. */
533
+ spend(clientId, nonce, atMillis) {
534
+ const key = replayLedgerKey(clientId, nonce);
535
+ if (this.spent.has(key)) {
536
+ return false;
537
+ }
538
+ this.spent.set(key, atMillis);
539
+ return true;
540
+ }
541
+ /** Drops entries older than the window, judged against `nowMillis`. */
542
+ prune(nowMillis, windowMillis) {
543
+ for (const [key, spentAtMillis] of this.spent) {
544
+ if (nowMillis - spentAtMillis > windowMillis) {
545
+ this.spent.delete(key);
546
+ }
547
+ }
548
+ }
549
+ get size() {
550
+ return this.spent.size;
551
+ }
552
+ clear() {
553
+ this.spent.clear();
554
+ }
555
+ };
556
+ var MemoryReplayStore = class {
557
+ constructor(windowMillis = DEFAULT_REPLAY_WINDOW_MILLIS) {
558
+ this.windowMillis = windowMillis;
559
+ }
560
+ ledger = new MemoryReplayLedger();
561
+ async isSpent(clientId, nonce) {
562
+ this.ledger.prune(Date.now(), this.windowMillis);
563
+ return this.ledger.isSpent(clientId, nonce);
564
+ }
565
+ async spend(clientId, nonce) {
566
+ const now = Date.now();
567
+ this.ledger.prune(now, this.windowMillis);
568
+ return this.ledger.spend(clientId, nonce, now);
569
+ }
570
+ };
571
+ var RedisReplayStore = class {
572
+ constructor(windowMillis = DEFAULT_REPLAY_WINDOW_MILLIS) {
573
+ this.windowMillis = windowMillis;
574
+ }
575
+ async isSpent(clientId, nonce) {
576
+ return await this.cache().exists(this.key(clientId, nonce)) === 1;
577
+ }
578
+ async spend(clientId, nonce) {
579
+ return await this.cache().set(this.key(clientId, nonce), "1", "PX", this.windowMillis, "NX") === "OK";
580
+ }
581
+ cache() {
582
+ const cache = getCache();
583
+ if (!cache) {
584
+ throw new Error("client-proof replay ledger: cache is not available");
585
+ }
586
+ return cache;
587
+ }
588
+ key(clientId, nonce) {
589
+ return `spfn:auth:client-proof:replay:${sha256Hex(Buffer.from(replayLedgerKey(clientId, nonce), "utf8"))}`;
590
+ }
591
+ };
592
+ var configured = null;
593
+ function configureClientProofReplayStore(store) {
594
+ configured = store;
595
+ }
596
+ function getClientProofReplayStore() {
597
+ configured ??= new MemoryReplayStore();
598
+ return configured;
599
+ }
600
+
493
601
  // src/server/client-proof/state.ts
494
602
  function systemClock() {
495
603
  return { nowMillis: () => Date.now() };
@@ -506,16 +614,14 @@ var TestClock = class {
506
614
  }
507
615
  };
508
616
  var DEFAULT_SESSION_TTL_MILLIS = 6e5;
509
- function replayKeyOf(clientId, nonce) {
510
- return `${clientId}${nonce}`;
511
- }
512
617
  var ClientProofState = class {
513
618
  replayWindowMillis;
514
619
  clock;
515
- keys = /* @__PURE__ */ new Map();
620
+ initialPublicKeys;
621
+ publicKeys = /* @__PURE__ */ new Map();
516
622
  sessions = /* @__PURE__ */ new Map();
517
- /** replayKeyOf(...) the issuedAtMillis it was spent at. */
518
- spentNonces = /* @__PURE__ */ new Map();
623
+ /** The replay ledger — the shared memory implementation, used dev-only here. */
624
+ spentNonces = new MemoryReplayLedger();
519
625
  revokedKeyIds = /* @__PURE__ */ new Set();
520
626
  holds = /* @__PURE__ */ new Map();
521
627
  initialSessionTtlMillis;
@@ -530,10 +636,22 @@ var ClientProofState = class {
530
636
  this.initialSessionTtlMillis = options.sessionTtlMillis ?? DEFAULT_SESSION_TTL_MILLIS;
531
637
  this.sessionTtlMillis = this.initialSessionTtlMillis;
532
638
  this.replayWindowMillis = options.replayWindowMillis ?? DEFAULT_REPLAY_WINDOW_MILLIS;
533
- for (const [keyId, key] of Object.entries(options.keys)) {
534
- this.keys.set(keyId, typeof key === "string" ? new TextEncoder().encode(key) : key);
639
+ this.initialPublicKeys = new Map(
640
+ Object.entries(options.publicKeys).map(([keyId, spki]) => [keyId, parseClientProofPublicKey(spki)])
641
+ );
642
+ for (const [keyId, key] of this.initialPublicKeys) {
643
+ this.publicKeys.set(keyId, key);
535
644
  }
536
645
  }
646
+ // ---- key registration --------------------------------------------------
647
+ /**
648
+ * Registers (or replaces) the public key `keyId` presents proofs under.
649
+ *
650
+ * @throws when the key is not base64 SPKI DER naming a P-256 key.
651
+ */
652
+ registerPublicKey(keyId, publicKeySpkiDerBase64) {
653
+ this.publicKeys.set(keyId, parseClientProofPublicKey(publicKeySpkiDerBase64));
654
+ }
537
655
  // ---- admission ---------------------------------------------------------
538
656
  /**
539
657
  * Runs the contract's checks in the contract's order and returns the
@@ -555,18 +673,17 @@ var ClientProofState = class {
555
673
  if (age < 0 || age > this.replayWindowMillis) {
556
674
  return ClientProofRefusal.proofExpired();
557
675
  }
558
- const replayKey = replayKeyOf(args.clientId, args.proofInput.nonce);
559
- if (this.spentNonces.has(replayKey)) {
676
+ if (this.spentNonces.isSpent(args.clientId, args.proofInput.nonce)) {
560
677
  return ClientProofRefusal.proofReplayed();
561
678
  }
562
- const key = this.keys.get(args.keyId);
563
- if (key === void 0) {
679
+ const publicKey = this.publicKeys.get(args.keyId);
680
+ if (publicKey === void 0) {
564
681
  return ClientProofRefusal.proofInvalid();
565
682
  }
566
- if (!constantTimeEqualsProof(computeClientProof(args.proofInput, key), args.presentedProof)) {
683
+ if (!verifyClientProof(args.proofInput, args.presentedProof, publicKey)) {
567
684
  return ClientProofRefusal.proofInvalid();
568
685
  }
569
- this.spentNonces.set(replayKey, Number(args.proofInput.issuedAtMillis));
686
+ this.spentNonces.spend(args.clientId, args.proofInput.nonce, Number(args.proofInput.issuedAtMillis));
570
687
  return null;
571
688
  }
572
689
  // ---- sessions ----------------------------------------------------------
@@ -599,8 +716,12 @@ var ClientProofState = class {
599
716
  setSessionTtlMillis(millis) {
600
717
  this.sessionTtlMillis = millis;
601
718
  }
602
- /** Returns the state to how it started, counters included. */
719
+ /** Returns the state to how it started, counters and registered keys included. */
603
720
  reset() {
721
+ this.publicKeys.clear();
722
+ for (const [keyId, key] of this.initialPublicKeys) {
723
+ this.publicKeys.set(keyId, key);
724
+ }
604
725
  this.sessions.clear();
605
726
  this.spentNonces.clear();
606
727
  this.revokedKeyIds.clear();
@@ -678,11 +799,7 @@ var ClientProofState = class {
678
799
  this.sessions.delete(sessionId);
679
800
  }
680
801
  }
681
- for (const [key, issuedAtMillis] of this.spentNonces) {
682
- if (nowMillis - issuedAtMillis > this.replayWindowMillis) {
683
- this.spentNonces.delete(key);
684
- }
685
- }
802
+ this.spentNonces.prune(nowMillis, this.replayWindowMillis);
686
803
  }
687
804
  };
688
805
 
@@ -826,6 +943,78 @@ var CONTRACT_OPERATIONS = [
826
943
  summary: "Authenticated paged read covering optional fields and arrays."
827
944
  }
828
945
  ];
946
+ var AUTH_SURFACE_OPERATIONS = [
947
+ {
948
+ id: "auth.enroll.register",
949
+ method: "POST",
950
+ path: "/_auth/register",
951
+ authProfile: "none",
952
+ requiresSession: false,
953
+ requestType: "RegisterRequest",
954
+ responseType: "RegisterResponse",
955
+ summary: "Registers an account with a verification token and enrolls the client-generated public key."
956
+ },
957
+ {
958
+ id: "auth.enroll.login",
959
+ method: "POST",
960
+ path: "/_auth/login",
961
+ authProfile: "none",
962
+ requiresSession: false,
963
+ requestType: "LoginRequest",
964
+ responseType: "LoginResponse",
965
+ summary: "Authenticates with password credentials and enrolls a fresh client-generated public key."
966
+ },
967
+ {
968
+ id: "auth.enroll.oauthNative",
969
+ method: "POST",
970
+ path: "/_auth/oauth/{provider}/native",
971
+ authProfile: "none",
972
+ requiresSession: false,
973
+ requestType: "OauthNativeRequest",
974
+ responseType: "OauthNativeResponse",
975
+ summary: "Verifies a native/web social id_token server-side and enrolls the client-generated public key."
976
+ },
977
+ {
978
+ id: "auth.keys.rotate",
979
+ method: "POST",
980
+ path: "/_auth/keys/rotate",
981
+ authProfile: "clientProofV1",
982
+ requiresSession: false,
983
+ requestType: "RotateKeyRequest",
984
+ responseType: "RotateKeyResponse",
985
+ summary: "Replaces the authenticated key with a new client-generated public key before its TTL runs out."
986
+ },
987
+ {
988
+ id: "auth.keys.list",
989
+ method: "POST",
990
+ path: "/_auth/keys/list",
991
+ authProfile: "clientProofV1",
992
+ requiresSession: false,
993
+ requestType: "ListKeysRequest",
994
+ responseType: "ListKeysResponse",
995
+ summary: "Lists the keys registered to the caller, one per device that can sign for them."
996
+ },
997
+ {
998
+ id: "auth.keys.revoke",
999
+ method: "POST",
1000
+ path: "/_auth/keys/revoke",
1001
+ authProfile: "clientProofV1",
1002
+ requiresSession: false,
1003
+ requestType: "RevokeKeyRequest",
1004
+ responseType: "RevokeKeyResponse",
1005
+ summary: "Revokes one of the caller's keys, signing that device out."
1006
+ },
1007
+ {
1008
+ id: "auth.keys.revokeAll",
1009
+ method: "POST",
1010
+ path: "/_auth/keys/revoke-all",
1011
+ authProfile: "clientProofV1",
1012
+ requiresSession: false,
1013
+ requestType: "RevokeAllKeysRequest",
1014
+ responseType: "RevokeAllKeysResponse",
1015
+ summary: "Revokes every key the caller has, sparing the calling device unless asked otherwise."
1016
+ }
1017
+ ];
829
1018
  var ContractTypeError = class extends Error {
830
1019
  constructor() {
831
1020
  super("not the declared contract type");
@@ -937,6 +1126,8 @@ async function handleControlRequest(state, controlToken, path, request) {
937
1126
  case "/control/expire-sessions":
938
1127
  state.expireSessions();
939
1128
  return ok();
1129
+ case "/control/register-key":
1130
+ return registerKey(state, body);
940
1131
  case "/control/revoke-key":
941
1132
  return revokeKey(state, body);
942
1133
  case "/control/session-ttl":
@@ -961,6 +1152,22 @@ function stats(state) {
961
1152
  ["spentNonceCount", BigInt(counters.spentNonceCount)]
962
1153
  ])));
963
1154
  }
1155
+ function registerKey(state, body) {
1156
+ const keyId = stringField(body, "keyId");
1157
+ const publicKey = stringField(body, "publicKey");
1158
+ if (keyId === null) {
1159
+ return badRequest("keyId");
1160
+ }
1161
+ if (publicKey === null) {
1162
+ return badRequest("publicKey");
1163
+ }
1164
+ try {
1165
+ state.registerPublicKey(keyId, publicKey);
1166
+ } catch {
1167
+ return badRequest("publicKey");
1168
+ }
1169
+ return ok();
1170
+ }
964
1171
  function revokeKey(state, body) {
965
1172
  const keyId = stringField(body, "keyId");
966
1173
  if (keyId === null) {
@@ -1226,6 +1433,7 @@ function createClientProofGuard(state, options = {}) {
1226
1433
  }
1227
1434
  export {
1228
1435
  ABSENT_BODY_SHA256,
1436
+ AUTH_SURFACE_OPERATIONS,
1229
1437
  CLIENT_PROOF_CONTENT_TYPE,
1230
1438
  CLIENT_PROOF_HEADERS,
1231
1439
  CLIENT_PROOF_PROFILE,
@@ -1240,12 +1448,16 @@ export {
1240
1448
  DEFAULT_SESSION_TTL_MILLIS,
1241
1449
  DEV_CATALOGUE,
1242
1450
  DEV_MAX_LIMIT,
1451
+ MemoryReplayLedger,
1452
+ MemoryReplayStore,
1453
+ PROOF_SIGNATURE_BYTES,
1454
+ PROOF_SIGNATURE_HEX_LENGTH,
1243
1455
  ProofInputError,
1456
+ RedisReplayStore,
1244
1457
  TestClock,
1245
1458
  admitClientProofRequest,
1246
1459
  canonicalProofInput,
1247
- computeClientProof,
1248
- constantTimeEqualsProof,
1460
+ configureClientProofReplayStore,
1249
1461
  createClientProofDevHandler,
1250
1462
  createClientProofGuard,
1251
1463
  decodeEchoRequest,
@@ -1255,10 +1467,17 @@ export {
1255
1467
  encodeEchoResponse,
1256
1468
  encodeHandshakeResponse,
1257
1469
  encodeListItemsResponse,
1470
+ getClientProofReplayStore,
1258
1471
  isCanonicalBytes,
1472
+ isRequestContentType,
1259
1473
  newHexId,
1260
1474
  parseCanonicalJson,
1475
+ parseClientProofPublicKey,
1476
+ readCredentials,
1477
+ replayLedgerKey,
1261
1478
  sha256Hex,
1262
- systemClock
1479
+ signClientProof,
1480
+ systemClock,
1481
+ verifyClientProof
1263
1482
  };
1264
1483
  //# sourceMappingURL=client-proof.js.map