@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 +52 -2
- package/dist/client-proof.d.ts +23 -4
- package/dist/client-proof.js +57 -2
- package/dist/client-proof.js.map +1 -1
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +162 -1
- package/dist/server.js +213 -23
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
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.
|
|
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.**
|
package/dist/client-proof.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/client-proof.js
CHANGED
|
@@ -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.
|
|
1345
|
+
var CONTRACT_VERSION = "0.9.0";
|
|
1311
1346
|
var CONTRACT_MAJOR = 0;
|
|
1312
|
-
var CONTRACT_SUPPORTED_RANGE = ">=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,
|