@spfn/auth 0.3.0-beta.2 → 0.3.0-beta.4

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
@@ -78,10 +78,10 @@ applied globally via `.use()`. Public routes opt out per-route with `.skip(['aut
78
78
  ```typescript
79
79
  import { defineRouter } from '@spfn/core/route';
80
80
  import { authRouter, authenticate } from '@spfn/auth/server';
81
- import { getHealth } from './routes/health';
81
+ import { getStatus } from './routes/status';
82
82
 
83
83
  export const appRouter = defineRouter({
84
- getHealth,
84
+ getStatus,
85
85
  // ...your routes
86
86
  })
87
87
  .packages([authRouter]) // mounts /_auth/* and exposes routes on authApi
@@ -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
@@ -917,6 +918,12 @@ the fixed-string contract error envelope (`PROOF_INVALID` · `PROOF_REPLAYED` ·
917
918
  `SESSION_REVOKED` · `PROFILE_REJECTED` · `CONTRACT_UNSUPPORTED` — SDKs classify by code, never
918
919
  HTTP status).
919
920
 
921
+ Before minting the first proof in each client process, the client calls the built-in
922
+ `GET /_core/time` operation (`core.time`) and establishes its proof epoch from
923
+ `serverTimeMillis`. This prerequisite is unproven and session-free. If the operation is
924
+ unavailable or its response cannot be decoded, proof minting fails closed — there is no silent
925
+ fallback to the device's unsynchronized wall clock.
926
+
920
927
  - Wire headers (D23, ratified): `x-spfn-auth-profile`, `x-spfn-client-id`, `x-spfn-key-id`,
921
928
  `x-spfn-nonce`, `x-spfn-issued-at`, `x-spfn-proof`, `x-spfn-session`.
922
929
  - A request body must be **byte-canonical** — a body that parses but re-encodes differently is
@@ -944,6 +951,29 @@ HTTP status).
944
951
  construction or through the `/control/register-key` hook; the private half never reaches
945
952
  the server. No persistence — a production enrollment/rotation story is phase 2.
946
953
 
954
+ ### Clock synchronization and proof-time boundaries (contract 0.9.0)
955
+
956
+ `core.time` is imported from `@spfn/core` rather than restated by auth: operation ID, method,
957
+ path, auth class, session requirement, and the closed `ServerTimeResponse` schema all come from
958
+ the core route contract. The mobile contract records it as a bodyless GET prerequisite and
959
+ requires one synchronization before the first proof minted in each process. It does not prescribe
960
+ persistent offset storage, retry sleeps, or device-specific margins.
961
+
962
+ The server admission rule remains strict: `age = serverNow - issuedAtMillis` must satisfy
963
+ `0 <= age <= 300000`. Synchronization does not widen the replay window or change nonce retention.
964
+ A refused request still leaves its nonce unused; only admission spends it.
965
+
966
+ | `serverNow - issuedAtMillis` | Result |
967
+ |---:|---|
968
+ | `0` | accept |
969
+ | `-1` (proof is 1 ms in the future) | `PROOF_EXPIRED` |
970
+ | `300000` | accept |
971
+ | `300001` | `PROOF_EXPIRED` |
972
+
973
+ When `core.time` cannot be read, the client must surface that synchronization failure and stop
974
+ before sending a proof. Using `Date.now()` or a platform wall clock as an implicit fallback would
975
+ reintroduce the skew failure this prerequisite closes.
976
+
947
977
  ### The contract version on the wire (contract 0.6.0)
948
978
 
949
979
  A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
@@ -992,6 +1022,7 @@ Every operation in the exported bundle carries `since` — the contract version
992
1022
  | `auth.clientProof.handshake`, `echo.send`, `items.list` | 0.1.0 |
993
1023
  | `auth.enroll.register`, `auth.enroll.login`, `auth.enroll.oauthNative`, `auth.keys.rotate` | 0.3.0 |
994
1024
  | `auth.keys.list`, `auth.keys.revoke`, `auth.keys.revokeAll` | 0.4.1 |
1025
+ | `core.time` | 0.9.0 |
995
1026
 
996
1027
  - **This is history, not policy.** The mobile contract's compatibility policy is `allOrNothing`: one
997
1028
  contract version passes or refuses the whole surface, so these three fields change no verdict here.
@@ -1401,7 +1401,7 @@ declare module 'hono' {
1401
1401
  * .build();
1402
1402
  *
1403
1403
  * // In route file - skip auth for public routes
1404
- * export const publicRoute = route.get('/health')
1404
+ * export const publicRoute = route.get('/status')
1405
1405
  * .skip(['auth']) // Type-safe skip
1406
1406
  * .handler(async (c) => c.success({ status: 'ok' }));
1407
1407
  *
@@ -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,