@oxy.so/core 7.11.1 → 7.12.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Holder-context evidence: the server's point-in-time observation of which
3
+ * shared-device context a bearer is installed in, bound to ONE installation of
4
+ * that bearer in this client (ADR 0053).
5
+ *
6
+ * Evidence is never authorization. It lets a caller that already captured a
7
+ * viewer intent prove, before a write is dispatched, that the bearer the write
8
+ * will carry belongs to that viewer's account and actor, and then pin the write
9
+ * to the exact installation with `expectedCredentialGeneration`. Anything that
10
+ * cannot be proven is `unavailable`, and the caller keeps its existing checks.
11
+ */
12
+ import type { HolderContextEvidenceV1Response } from '@oxy.so/contracts';
13
+ import type { CredentialGeneration } from '../HttpService';
14
+
15
+ /** The identifiers the server observed; none of them is a credential. */
16
+ export type HolderEvidenceIdentity = Readonly<
17
+ Pick<
18
+ HolderContextEvidenceV1Response,
19
+ 'deviceId' | 'deviceSessionId' | 'contextId' | 'actorId' | 'accountId' | 'sessionId'
20
+ >
21
+ >;
22
+
23
+ export interface HolderEvidence {
24
+ /** The installation of the bearer this evidence was observed for. */
25
+ readonly generation: CredentialGeneration;
26
+ readonly identity: HolderEvidenceIdentity;
27
+ /** The device-session revision the server observed. */
28
+ readonly deviceRevision: number;
29
+ /** Epoch milliseconds: the earlier of the bearer and session expiry. */
30
+ readonly expiresAtMs: number;
31
+ }
32
+
33
+ /**
34
+ * Why no evidence exists. Every reason means "keep the existing checks": none of
35
+ * them says the session is revoked, and none may clear or refresh credentials.
36
+ *
37
+ * - `no_credential`: no bearer is installed, or it comes from a pull-only provider.
38
+ * - `no_holder`: this client holds no device credential to prove the holder with.
39
+ * - `changed`: the bearer was replaced while the evidence was being acquired.
40
+ * - `unsupported`: a delegated, OAuth or legacy bearer, or the endpoint is not offered.
41
+ * - `binding_unavailable`: the server could not match holder, context and bearer.
42
+ * - `unavailable`: the endpoint is off, rate limited, unreachable or answered wrongly.
43
+ */
44
+ export type HolderEvidenceUnavailableReason =
45
+ | 'no_credential'
46
+ | 'no_holder'
47
+ | 'changed'
48
+ | 'unsupported'
49
+ | 'binding_unavailable'
50
+ | 'unavailable';
51
+
52
+ export type HolderEvidenceResult =
53
+ | { readonly status: 'ok'; readonly evidence: HolderEvidence }
54
+ | { readonly status: 'unavailable'; readonly reason: HolderEvidenceUnavailableReason };
55
+
56
+ /** The one comparison that decides whether two observations name the same viewer context. */
57
+ export function sameHolderIdentity(a: HolderEvidenceIdentity, b: HolderEvidenceIdentity): boolean {
58
+ return (
59
+ a.deviceId === b.deviceId &&
60
+ a.deviceSessionId === b.deviceSessionId &&
61
+ a.contextId === b.contextId &&
62
+ a.actorId === b.actorId &&
63
+ a.accountId === b.accountId &&
64
+ a.sessionId === b.sessionId
65
+ );
66
+ }
@@ -12,6 +12,20 @@ import type { ClientSession } from '../models/session';
12
12
  import type { User } from '../models/interfaces';
13
13
  import { normalizeUserIdentity } from '../utils/userIdentity';
14
14
  import { OxyApiError } from '../OxyServices.errors';
15
+ import { checkContract, holderContextEvidenceV1ResponseCheck } from '@oxy.so/contracts/client';
16
+ import type { CredentialGeneration } from '../HttpService';
17
+ import type {
18
+ HolderEvidence,
19
+ HolderEvidenceResult,
20
+ HolderEvidenceUnavailableReason,
21
+ } from './holderEvidence';
22
+
23
+ /** How long a refusal (disabled, rate limited, unreachable) is remembered per installation. */
24
+ const EVIDENCE_REFUSAL_MS = 5_000;
25
+ const unavailable = (reason: HolderEvidenceUnavailableReason): HolderEvidenceResult => ({
26
+ status: 'unavailable',
27
+ reason,
28
+ });
15
29
 
16
30
  /**
17
31
  * Reads the device credential this client holds, if any — `{ deviceId,
@@ -42,12 +56,21 @@ interface AccessTokenClaims {
42
56
  exp?: number;
43
57
  userId?: string;
44
58
  id?: string;
59
+ sessionId?: string;
60
+ deviceId?: string;
61
+ device_context_id?: string;
62
+ act?: { sub?: string };
45
63
  }
46
64
 
47
65
  export class SessionApi {
48
66
  private deviceCredentialProvider: DeviceCredentialProvider | null = null;
49
67
  private decodedFor: string | null = null;
50
68
  private decoded: AccessTokenClaims | null = null;
69
+ /** One server round trip per installed bearer; the key is the opaque generation. */
70
+ private readonly evidence = new WeakMap<
71
+ CredentialGeneration,
72
+ { result: Promise<HolderEvidenceResult>; settledAt?: number }
73
+ >();
51
74
 
52
75
  constructor(protected readonly ctx: OxyContext) {}
53
76
 
@@ -163,6 +186,128 @@ export class SessionApi {
163
186
  }
164
187
  }
165
188
 
189
+ // ── Holder-context evidence (ADR 0053) ───────────────────────────────────
190
+
191
+ /**
192
+ * Ask the server which shared-device context the INSTALLED bearer belongs to,
193
+ * and bind the answer to that exact installation.
194
+ *
195
+ * The result is honored only if the credential generation captured before
196
+ * the request is still current when the answer is committed, so a refresh,
197
+ * setter, account or actor switch, cross-tab change, restore or A to B to A
198
+ * during the request yields `changed`. The answer must also agree with the
199
+ * claims of the bearer that was captured; a disagreement is
200
+ * `binding_unavailable`. Nothing here refreshes, clears or mints a credential,
201
+ * and no outcome is a revocation verdict: callers keep their existing checks.
202
+ * Concurrent callers share one request per installation.
203
+ */
204
+ observeHolderEvidence(): Promise<HolderEvidenceResult> {
205
+ const generation = this.ctx.http.captureCredentialGeneration();
206
+ const token = this.ctx.http.getAccessToken();
207
+ if (!generation || !token) return Promise.resolve(unavailable('no_credential'));
208
+ const known = this.evidence.get(generation);
209
+ if (known) {
210
+ const refused = known.settledAt !== undefined;
211
+ if (!refused || Date.now() - (known.settledAt as number) < EVIDENCE_REFUSAL_MS) {
212
+ return known.result.then((result) =>
213
+ result.status === 'ok' && result.evidence.expiresAtMs <= Date.now()
214
+ ? this.refreshEvidence(generation, token)
215
+ : result,
216
+ );
217
+ }
218
+ }
219
+ return this.refreshEvidence(generation, token);
220
+ }
221
+
222
+ /** Whether evidence still names the installed bearer and has not expired. */
223
+ isHolderEvidenceCurrent(evidence: HolderEvidence): boolean {
224
+ return (
225
+ Date.now() < evidence.expiresAtMs &&
226
+ this.ctx.http.isCredentialGenerationCurrent(evidence.generation)
227
+ );
228
+ }
229
+
230
+ private refreshEvidence(
231
+ generation: CredentialGeneration,
232
+ token: string,
233
+ ): Promise<HolderEvidenceResult> {
234
+ const entry: { result: Promise<HolderEvidenceResult>; settledAt?: number } = {
235
+ result: this.acquireEvidence(generation, token).then((result) => {
236
+ if (result.status !== 'ok') entry.settledAt = Date.now();
237
+ return result;
238
+ }),
239
+ };
240
+ this.evidence.set(generation, entry);
241
+ return entry.result;
242
+ }
243
+
244
+ private async acquireEvidence(
245
+ generation: CredentialGeneration,
246
+ token: string,
247
+ ): Promise<HolderEvidenceResult> {
248
+ let claims: AccessTokenClaims;
249
+ try {
250
+ claims = jwtDecode<AccessTokenClaims>(token);
251
+ } catch {
252
+ return unavailable('unsupported');
253
+ }
254
+ const proof = await this.readDeviceProof();
255
+ if (!proof) return unavailable('no_holder');
256
+ let response: unknown;
257
+ try {
258
+ response = await this.ctx.request<unknown>(
259
+ 'POST',
260
+ '/session/device/context-evidence-v1',
261
+ proof,
262
+ {
263
+ cache: false,
264
+ retry: false,
265
+ expectedCredentialGeneration: generation,
266
+ },
267
+ );
268
+ } catch (error) {
269
+ if ((error as { code?: unknown } | null)?.code === 'AUTH_SESSION_CHANGED') {
270
+ return unavailable('changed');
271
+ }
272
+ const status = error instanceof OxyApiError ? error.status : 0;
273
+ if (status === 403) return unavailable('unsupported');
274
+ if (status === 409) return unavailable('binding_unavailable');
275
+ return unavailable('unavailable');
276
+ }
277
+ const parsed = checkContract(holderContextEvidenceV1ResponseCheck, response);
278
+ if (!parsed) return unavailable('unavailable');
279
+ // Commit synchronously: nothing may replace the bearer between this check
280
+ // and the evidence becoming visible to its caller.
281
+ if (!this.ctx.http.isCredentialGenerationCurrent(generation)) return unavailable('changed');
282
+ const expiresAtMs = Date.parse(parsed.expiresAt);
283
+ if (
284
+ parsed.deviceId !== proof.deviceId ||
285
+ parsed.accountId !== claims.userId ||
286
+ parsed.sessionId !== claims.sessionId ||
287
+ (claims.act?.sub !== undefined && parsed.actorId !== claims.act.sub) ||
288
+ (claims.device_context_id !== undefined && parsed.contextId !== claims.device_context_id)
289
+ ) {
290
+ return unavailable('binding_unavailable');
291
+ }
292
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
293
+ return unavailable('unavailable');
294
+ }
295
+ const evidence: HolderEvidence = Object.freeze({
296
+ generation,
297
+ identity: Object.freeze({
298
+ deviceId: parsed.deviceId,
299
+ deviceSessionId: parsed.deviceSessionId,
300
+ contextId: parsed.contextId,
301
+ actorId: parsed.actorId,
302
+ accountId: parsed.accountId,
303
+ sessionId: parsed.sessionId,
304
+ }),
305
+ deviceRevision: parsed.revision,
306
+ expiresAtMs,
307
+ });
308
+ return { status: 'ok', evidence };
309
+ }
310
+
166
311
  // ── The server session ───────────────────────────────────────────────────
167
312
 
168
313
  /** Ask the server whether the current access token is valid. */
package/src/index.ts CHANGED
@@ -48,7 +48,15 @@ export type {
48
48
  AuthenticatedResponseRequest,
49
49
  AuthRefreshReason,
50
50
  AuthRefreshHandler,
51
+ CredentialGeneration,
51
52
  } from './HttpService';
53
+ export { sameHolderIdentity } from './api/holderEvidence';
54
+ export type {
55
+ HolderEvidence,
56
+ HolderEvidenceIdentity,
57
+ HolderEvidenceResult,
58
+ HolderEvidenceUnavailableReason,
59
+ } from './api/holderEvidence';
52
60
 
53
61
  // The namespaces — their classes (for typing) and their input/output types.
54
62
  export type { SessionApi, DeviceCredentialProvider, SessionValidation } from './api/session';