@spfn/auth 0.3.0-beta.3 → 0.3.0-beta.5

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
@@ -236,12 +236,13 @@ await authApi.revokeAllKeys.call({ body: {} }); // other devices o
236
236
  await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
237
237
  ```
238
238
 
239
- > **All three are POST with their arguments in the body, deliberately.** The mobile auth
239
+ > **All three key-management operations are POST with their arguments in the body, deliberately.** The mobile auth
240
240
  > profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
241
241
  > bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
242
242
  > client and server could disagree on the signed string over percent-encoding, a trailing
243
243
  > slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
244
- > naming the cause. Every operation in the contract is shaped this way.
244
+ > naming the cause. Proof-bearing auth operations are shaped this way; the unproven,
245
+ > bodyless `core.time` synchronization prerequisite is the explicit exception.
245
246
 
246
247
  - **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
247
248
  The list exists to recognise a device and point at it; the full fingerprint is what a native
@@ -826,6 +827,8 @@ Notes:
826
827
  seeding in `initializeAuth()`.
827
828
  - OAuth signups have no client-typed fields unless you pass `metadata` at OAuth start — decide
828
829
  per channel (reject, or allow and collect during onboarding).
830
+ - `email` arrives trimmed and lower-cased, the same form the account is stored under, so a
831
+ denylist or domain allowlist keyed on the address is not walked past by capitalizing it.
829
832
  - On the `oauth` channel `email` is the provider-reported address and may be **unverified**
830
833
  (the created account then stores `email` as `null`). The context carries
831
834
  `emailVerified` — an email-based allow/block policy must check it before trusting `email`.
@@ -917,6 +920,12 @@ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` ·
917
920
  `SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
918
921
  HTTP status).
919
922
 
923
+ Before minting the first proof in each client process, the client calls the built-in
924
+ `GET /_core/time` operation (`core.time`) and establishes its proof epoch from
925
+ `serverTimeMillis`. This prerequisite is unproven and session-free. If the operation is
926
+ unavailable or its response cannot be decoded, proof minting fails closed — there is no silent
927
+ fallback to the device's unsynchronized wall clock.
928
+
920
929
  - Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
921
930
  `x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
922
931
  - A request body must be **byte-canonical** — a body that parses but re-encodes differently is
@@ -944,6 +953,29 @@ HTTP status).
944
953
  construction or through the `/control/register-key` hook; the private half never reaches
945
954
  the server. No persistence — a production enrollment/rotation story is phase 2.
946
955
 
956
+ ### Clock synchronization and proof-time boundaries (contract 0.9.0)
957
+
958
+ `core.time` is imported from `@spfn/core` rather than restated by auth: operation ID, method,
959
+ path, auth class, session requirement, and the closed `ServerTimeResponse` schema all come from
960
+ the core route contract. The mobile contract records it as a bodyless GET prerequisite and
961
+ requires one synchronization before the first proof minted in each process. It does not prescribe
962
+ persistent offset storage, retry sleeps, or device-specific margins.
963
+
964
+ The server admission rule remains strict: `age = serverNow - issuedAtMillis` must satisfy
965
+ `0 <= age <= 300000`. Synchronization does not widen the replay window or change nonce retention.
966
+ A refused request still leaves its nonce unused; only admission spends it.
967
+
968
+ | `serverNow - issuedAtMillis` | Result |
969
+ |---:|---|
970
+ | `0` | accept |
971
+ | `-1` (proof is 1 ms in the future) | `PROOF_EXPIRED` |
972
+ | `300000` | accept |
973
+ | `300001` | `PROOF_EXPIRED` |
974
+
975
+ When `core.time` cannot be read, the client must surface that synchronization failure and stop
976
+ before sending a proof. Using `Date.now()` or a platform wall clock as an implicit fallback would
977
+ reintroduce the skew failure this prerequisite closes.
978
+
947
979
  ### The contract version on the wire (contract 0.6.0)
948
980
 
949
981
  A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
@@ -992,6 +1024,7 @@ Every operation in the exported bundle carries `since` — the contract version
992
1024
  | `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
993
1025
  | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
994
1026
  | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
1027
+ | `core.time` | 0.9.0 |
995
1028
 
996
1029
  - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
997
1030
  contract version passes or refuses the whole surface, so these three fields change no verdict here.
@@ -1208,6 +1241,23 @@ authorization.
1208
1241
  The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1209
1242
  verified, active, and required to change their password on first login.
1210
1243
 
1244
+ **Is `Foo@Example.com` the same account as `foo@example.com`?**
1245
+ Yes. Addresses are trimmed and lower-cased on the way in and on the way out, so one person
1246
+ who capitalizes differently on different days reaches one account instead of creating a
1247
+ second. Nothing else is folded — Gmail's dot and `+` rules are that provider's delivery
1248
+ behaviour, not an internet rule, and applying them would merge addresses other providers
1249
+ treat as different people.
1250
+
1251
+ `createAuthLifecycle()` brings existing rows into the same form on startup. If two accounts
1252
+ differ only by capitalization, both are left exactly as they are and their user ids are
1253
+ logged as an error: which one is the real account, and what becomes of the other's data, is
1254
+ not a question the package can answer for you. Until you resolve it, the mixed-case one
1255
+ cannot sign in.
1256
+
1257
+ Admin seeding is unaffected either way. It recognizes a configured admin in whatever form
1258
+ the address was stored, so an account the backfill has not reached is skipped rather than
1259
+ duplicated into a second privileged row holding the configured password.
1260
+
1211
1261
  ## Pitfalls & anti-patterns
1212
1262
 
1213
1263
  - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
@@ -1,6 +1,7 @@
1
1
  import { KeyObject } from 'node:crypto';
2
2
  import { C as ClientProofRefusal } from './wire-version-CtzMKvBB.js';
3
3
  export { a as CLIENT_IDENTITY_HEADERS, b as CLIENT_KINDS, c as ClientIdentity, d as ClientKind, e as ClientProofErrorCode, S as SERVER_CONTRACT_HEADERS, f as applyServerContractHeaders, i as isAppKind, g as isContractVersionSupported, j as judgeClientIdentity, n as newHexId, r as readClientIdentity, s as serverContractHeaders } from './wire-version-CtzMKvBB.js';
4
+ import { CORE_TIME_OPERATION_ID } from '@spfn/core/server';
4
5
  import { MiddlewareHandler, Context } from 'hono';
5
6
 
6
7
  /**
@@ -397,8 +398,8 @@ declare function getClientProofReplayStore(): ClientProofReplayStore;
397
398
  */
398
399
 
399
400
  interface ContractOperation {
400
- id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll';
401
- method: 'POST';
401
+ id: 'auth.clientProof.handshake' | 'echo.send' | 'items.list' | 'auth.enroll.register' | 'auth.enroll.login' | 'auth.enroll.oauthNative' | 'auth.keys.rotate' | 'auth.keys.list' | 'auth.keys.revoke' | 'auth.keys.revokeAll' | typeof CORE_TIME_OPERATION_ID;
402
+ method: 'GET' | 'POST';
402
403
  path: string;
403
404
  /**
404
405
  * How a call is admitted. `clientProofV1` operations run the proof
@@ -408,7 +409,8 @@ interface ContractOperation {
408
409
  */
409
410
  authProfile: 'clientProofV1' | 'none';
410
411
  requiresSession: boolean;
411
- requestType: string;
412
+ /** Absent only when the operation has no request body. */
413
+ requestType?: string;
412
414
  responseType: string;
413
415
  summary: string;
414
416
  /**
@@ -439,6 +441,23 @@ interface ContractOperation {
439
441
  */
440
442
  removedIn?: string;
441
443
  }
444
+ /** Validated projection of the imported core route contract. */
445
+ declare const IMPORTED_CORE_TIME_CONTRACT: {
446
+ readonly id: "core.time";
447
+ readonly method: "GET";
448
+ readonly path: string;
449
+ readonly authProfile: "none";
450
+ readonly requiresSession: false;
451
+ readonly sourceSince: string;
452
+ };
453
+ /**
454
+ * The core capability clientProofV1 needs before the client can mint a proof.
455
+ *
456
+ * Transport and admission fields come from core's exported route contract so
457
+ * auth cannot silently restate a different path or policy. `since` is the
458
+ * mobile-contract history, not core's package-contract history.
459
+ */
460
+ declare const CORE_PREREQUISITE_OPERATIONS: readonly ContractOperation[];
442
461
  declare const CONTRACT_OPERATIONS: readonly ContractOperation[];
443
462
  /**
444
463
  * The `/_auth` surface exported into the mobile contract: enrollment, login
@@ -603,4 +622,4 @@ declare const CLIENT_IDENTITY_CONTEXT_KEY = "clientIdentity";
603
622
  */
604
623
  declare function createClientVersionMiddleware(): MiddlewareHandler;
605
624
 
606
- export { ABSENT_BODY_SHA256, AUTH_SURFACE_OPERATIONS, type Admission, CLIENT_IDENTITY_CONTEXT_KEY, CLIENT_PROOF_CONTENT_TYPE, CLIENT_PROOF_HEADERS, CLIENT_PROOF_PROFILE, CONTRACT_OPERATIONS, CONTROL_PREFIX, CONTROL_TOKEN_HEADER, CanonicalJsonError, type CanonicalJsonErrorCode, type CanonicalObject, type CanonicalValue, type ClientProofClock, type ClientProofContext, type ClientProofCredentials, type ClientProofDevHandler, type ClientProofDevHandlerOptions, type ClientProofGuardOptions, type ClientProofInput, ClientProofRefusal, type ClientProofReplayStore, ClientProofState, type ClientProofStateOptions, type ClientProofStats, type ContractItem, type ContractOperation, ContractTypeError, DEFAULT_REPLAY_WINDOW_MILLIS, DEFAULT_SESSION_TTL_MILLIS, DEV_CATALOGUE, DEV_MAX_LIMIT, type EchoRequest, type HandshakeRequest, type ListItemsRequest, MemoryReplayLedger, MemoryReplayStore, PROOF_SIGNATURE_BYTES, PROOF_SIGNATURE_HEX_LENGTH, ProofInputError, RedisReplayStore, TestClock, admitClientProofRequest, canonicalProofInput, clientProofRefusalResponse, configureClientProofReplayStore, createClientProofDevHandler, createClientProofGuard, createClientVersionMiddleware, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, getClientProofReplayStore, isCanonicalBytes, isRequestContentType, parseCanonicalJson, parseClientProofPublicKey, readCredentials, replayLedgerKey, sha256Hex, signClientProof, systemClock, verifyClientProof };
625
+ export { ABSENT_BODY_SHA256, AUTH_SURFACE_OPERATIONS, type Admission, CLIENT_IDENTITY_CONTEXT_KEY, CLIENT_PROOF_CONTENT_TYPE, CLIENT_PROOF_HEADERS, CLIENT_PROOF_PROFILE, CONTRACT_OPERATIONS, CONTROL_PREFIX, CONTROL_TOKEN_HEADER, CORE_PREREQUISITE_OPERATIONS, CanonicalJsonError, type CanonicalJsonErrorCode, type CanonicalObject, type CanonicalValue, type ClientProofClock, type ClientProofContext, type ClientProofCredentials, type ClientProofDevHandler, type ClientProofDevHandlerOptions, type ClientProofGuardOptions, type ClientProofInput, ClientProofRefusal, type ClientProofReplayStore, ClientProofState, type ClientProofStateOptions, type ClientProofStats, type ContractItem, type ContractOperation, ContractTypeError, DEFAULT_REPLAY_WINDOW_MILLIS, DEFAULT_SESSION_TTL_MILLIS, DEV_CATALOGUE, DEV_MAX_LIMIT, type EchoRequest, type HandshakeRequest, IMPORTED_CORE_TIME_CONTRACT, type ListItemsRequest, MemoryReplayLedger, MemoryReplayStore, PROOF_SIGNATURE_BYTES, PROOF_SIGNATURE_HEX_LENGTH, ProofInputError, RedisReplayStore, TestClock, admitClientProofRequest, canonicalProofInput, clientProofRefusalResponse, configureClientProofReplayStore, createClientProofDevHandler, createClientProofGuard, createClientVersionMiddleware, decodeEchoRequest, decodeHandshakeRequest, decodeListItemsRequest, encodeCanonicalJson, encodeEchoResponse, encodeHandshakeResponse, encodeListItemsResponse, getClientProofReplayStore, isCanonicalBytes, isRequestContentType, parseCanonicalJson, parseClientProofPublicKey, readCredentials, replayLedgerKey, sha256Hex, signClientProof, systemClock, verifyClientProof };
@@ -935,6 +935,37 @@ function isRequestContentType(value) {
935
935
  }
936
936
 
937
937
  // src/server/client-proof/contract-types.ts
938
+ import {
939
+ CORE_TIME_OPERATION_ID,
940
+ CORE_TIME_ROUTE
941
+ } from "@spfn/core/server";
942
+ function importCoreTimeContract() {
943
+ const { method, path, contract } = CORE_TIME_ROUTE;
944
+ if (method !== "GET" || typeof path !== "string" || contract?.auth !== "none" || contract.requiresSession !== false || typeof contract.since !== "string") {
945
+ throw new Error("core.time does not match the clientProofV1 synchronization prerequisite");
946
+ }
947
+ return {
948
+ id: CORE_TIME_OPERATION_ID,
949
+ method,
950
+ path,
951
+ authProfile: contract.auth,
952
+ requiresSession: contract.requiresSession,
953
+ sourceSince: contract.since
954
+ };
955
+ }
956
+ var IMPORTED_CORE_TIME_CONTRACT = importCoreTimeContract();
957
+ var CORE_PREREQUISITE_OPERATIONS = [
958
+ {
959
+ id: IMPORTED_CORE_TIME_CONTRACT.id,
960
+ method: IMPORTED_CORE_TIME_CONTRACT.method,
961
+ path: IMPORTED_CORE_TIME_CONTRACT.path,
962
+ authProfile: IMPORTED_CORE_TIME_CONTRACT.authProfile,
963
+ requiresSession: IMPORTED_CORE_TIME_CONTRACT.requiresSession,
964
+ responseType: "ServerTimeResponse",
965
+ summary: "Returns the server epoch used to timestamp clientProofV1 proofs.",
966
+ since: "0.9.0"
967
+ }
968
+ ];
938
969
  var CONTRACT_OPERATIONS = [
939
970
  {
940
971
  id: "auth.clientProof.handshake",
@@ -1287,6 +1318,10 @@ function answer(status, value) {
1287
1318
 
1288
1319
  // src/server/client-proof/contract-bundle.ts
1289
1320
  import { createHash as createHash2 } from "crypto";
1321
+ import {
1322
+ CORE_TIME_OPERATION_ID as CORE_TIME_OPERATION_ID2,
1323
+ ServerTimeResponseSchema
1324
+ } from "@spfn/core/server";
1290
1325
 
1291
1326
  // src/server/types.ts
1292
1327
  var KEY_ALGORITHM = ["ES256", "RS256"];
@@ -1307,16 +1342,34 @@ function isAppKind(kind) {
1307
1342
  }
1308
1343
 
1309
1344
  // src/server/client-proof/contract-bundle.ts
1310
- var CONTRACT_VERSION = "0.8.0";
1345
+ var CONTRACT_VERSION = "0.9.0";
1311
1346
  var CONTRACT_MAJOR = 0;
1312
- var CONTRACT_SUPPORTED_RANGE = ">=0.8.0 <0.9.0";
1347
+ var CONTRACT_SUPPORTED_RANGE = ">=0.9.0 <0.10.0";
1313
1348
  function required(name, type) {
1314
1349
  return { name, type, optional: false };
1315
1350
  }
1316
1351
  function optional(name, type) {
1317
1352
  return { name, type, optional: true };
1318
1353
  }
1354
+ function coreTimeResponseDeclaration() {
1355
+ if (ServerTimeResponseSchema.type !== "object" || ServerTimeResponseSchema.additionalProperties !== false) {
1356
+ throw new Error("core.time response must remain a closed object");
1357
+ }
1358
+ const requiredFields = new Set(ServerTimeResponseSchema.required);
1359
+ const fields = Object.entries(ServerTimeResponseSchema.properties).map(([name, schema]) => {
1360
+ if (schema.type !== "integer") {
1361
+ throw new Error(`core.time response field ${name} is outside the mobile type grammar`);
1362
+ }
1363
+ return {
1364
+ name,
1365
+ type: "integer",
1366
+ optional: !requiredFields.has(name)
1367
+ };
1368
+ });
1369
+ return { name: "ServerTimeResponse", fields };
1370
+ }
1319
1371
  var CONTRACT_TYPES = [
1372
+ coreTimeResponseDeclaration(),
1320
1373
  {
1321
1374
  name: "HandshakeRequest",
1322
1375
  fields: [
@@ -1790,6 +1843,7 @@ export {
1790
1843
  CONTRACT_OPERATIONS,
1791
1844
  CONTROL_PREFIX,
1792
1845
  CONTROL_TOKEN_HEADER,
1846
+ CORE_PREREQUISITE_OPERATIONS,
1793
1847
  CanonicalJsonError,
1794
1848
  ClientProofRefusal,
1795
1849
  ClientProofState,
@@ -1798,6 +1852,7 @@ export {
1798
1852
  DEFAULT_SESSION_TTL_MILLIS,
1799
1853
  DEV_CATALOGUE,
1800
1854
  DEV_MAX_LIMIT,
1855
+ IMPORTED_CORE_TIME_CONTRACT,
1801
1856
  MemoryReplayLedger,
1802
1857
  MemoryReplayStore,
1803
1858
  PROOF_SIGNATURE_BYTES,