@oxyhq/core 12.10.5 → 12.11.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.
Files changed (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +142 -37
  3. package/dist/cjs/i18n/locales/ar-SA.json +13 -1
  4. package/dist/cjs/i18n/locales/ca-ES.json +13 -1
  5. package/dist/cjs/i18n/locales/de-DE.json +13 -1
  6. package/dist/cjs/i18n/locales/en-US.json +13 -4
  7. package/dist/cjs/i18n/locales/es-ES.json +13 -4
  8. package/dist/cjs/i18n/locales/fr-FR.json +13 -1
  9. package/dist/cjs/i18n/locales/it-IT.json +13 -1
  10. package/dist/cjs/i18n/locales/ja-JP.json +13 -1
  11. package/dist/cjs/i18n/locales/ko-KR.json +13 -1
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
  15. package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
  23. package/dist/cjs/i18n/locales/pt-PT.json +13 -1
  24. package/dist/cjs/i18n/locales/zh-CN.json +13 -1
  25. package/dist/cjs/index.js +31 -5
  26. package/dist/cjs/mixins/OxyServices.auth.js +257 -17
  27. package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
  28. package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
  29. package/dist/cjs/mixins/index.js +5 -0
  30. package/dist/cjs/session/SessionClient.js +40 -4
  31. package/dist/cjs/session/accountDialogController.js +288 -41
  32. package/dist/cjs/session/hubSync.js +1 -1
  33. package/dist/cjs/session/identityPin.js +256 -0
  34. package/dist/cjs/session/identitySession.js +135 -0
  35. package/dist/cjs/session/projectSessionState.js +54 -15
  36. package/dist/cjs/session/refresh.js +73 -8
  37. package/dist/cjs/utils/commonsDelivery.js +59 -0
  38. package/dist/cjs/utils/oauthPkce.js +33 -3
  39. package/dist/esm/.tsbuildinfo +1 -1
  40. package/dist/esm/boot/sessionColdBoot.js +142 -37
  41. package/dist/esm/i18n/locales/ar-SA.json +13 -1
  42. package/dist/esm/i18n/locales/ca-ES.json +13 -1
  43. package/dist/esm/i18n/locales/de-DE.json +13 -1
  44. package/dist/esm/i18n/locales/en-US.json +13 -4
  45. package/dist/esm/i18n/locales/es-ES.json +13 -4
  46. package/dist/esm/i18n/locales/fr-FR.json +13 -1
  47. package/dist/esm/i18n/locales/it-IT.json +13 -1
  48. package/dist/esm/i18n/locales/ja-JP.json +13 -1
  49. package/dist/esm/i18n/locales/ko-KR.json +13 -1
  50. package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
  51. package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
  52. package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
  53. package/dist/esm/i18n/locales/locales/en-US.json +13 -4
  54. package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
  55. package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
  56. package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
  57. package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
  58. package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
  59. package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
  60. package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
  61. package/dist/esm/i18n/locales/pt-PT.json +13 -1
  62. package/dist/esm/i18n/locales/zh-CN.json +13 -1
  63. package/dist/esm/index.js +14 -1
  64. package/dist/esm/mixins/OxyServices.auth.js +257 -17
  65. package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
  66. package/dist/esm/mixins/OxyServices.notifications.js +60 -0
  67. package/dist/esm/mixins/index.js +5 -0
  68. package/dist/esm/session/SessionClient.js +40 -4
  69. package/dist/esm/session/accountDialogController.js +288 -41
  70. package/dist/esm/session/hubSync.js +1 -1
  71. package/dist/esm/session/identityPin.js +249 -0
  72. package/dist/esm/session/identitySession.js +131 -0
  73. package/dist/esm/session/projectSessionState.js +54 -15
  74. package/dist/esm/session/refresh.js +73 -8
  75. package/dist/esm/utils/commonsDelivery.js +54 -0
  76. package/dist/esm/utils/oauthPkce.js +31 -2
  77. package/dist/types/.tsbuildinfo +1 -1
  78. package/dist/types/boot/sessionColdBoot.d.ts +38 -1
  79. package/dist/types/index.d.ts +12 -4
  80. package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
  81. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
  82. package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
  83. package/dist/types/mixins/index.d.ts +2 -1
  84. package/dist/types/session/SessionClient.d.ts +28 -0
  85. package/dist/types/session/accountDialogController.d.ts +187 -9
  86. package/dist/types/session/identityPin.d.ts +87 -0
  87. package/dist/types/session/identitySession.d.ts +105 -0
  88. package/dist/types/session/projectSessionState.d.ts +21 -19
  89. package/dist/types/session/refresh.d.ts +32 -1
  90. package/dist/types/utils/commonsDelivery.d.ts +105 -0
  91. package/dist/types/utils/oauthPkce.d.ts +21 -1
  92. package/package.json +2 -2
  93. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
  94. package/src/boot/sessionColdBoot.ts +183 -37
  95. package/src/i18n/locales/ar-SA.json +13 -1
  96. package/src/i18n/locales/ca-ES.json +13 -1
  97. package/src/i18n/locales/de-DE.json +13 -1
  98. package/src/i18n/locales/en-US.json +13 -4
  99. package/src/i18n/locales/es-ES.json +13 -4
  100. package/src/i18n/locales/fr-FR.json +13 -1
  101. package/src/i18n/locales/it-IT.json +13 -1
  102. package/src/i18n/locales/ja-JP.json +13 -1
  103. package/src/i18n/locales/ko-KR.json +13 -1
  104. package/src/i18n/locales/pt-PT.json +13 -1
  105. package/src/i18n/locales/zh-CN.json +13 -1
  106. package/src/index.ts +49 -0
  107. package/src/mixins/OxyServices.auth.ts +464 -24
  108. package/src/mixins/OxyServices.deviceBoot.ts +54 -2
  109. package/src/mixins/OxyServices.notifications.ts +142 -0
  110. package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
  111. package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
  112. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
  113. package/src/mixins/index.ts +6 -0
  114. package/src/session/SessionClient.ts +57 -4
  115. package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
  116. package/src/session/__tests__/accountDialogController.test.ts +812 -1
  117. package/src/session/__tests__/hubSync.test.ts +51 -0
  118. package/src/session/__tests__/identityPin.test.ts +131 -0
  119. package/src/session/__tests__/identitySession.test.ts +194 -0
  120. package/src/session/__tests__/projectSessionState.test.ts +88 -0
  121. package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
  122. package/src/session/accountDialogController.ts +435 -44
  123. package/src/session/hubSync.ts +1 -1
  124. package/src/session/identityPin.ts +310 -0
  125. package/src/session/identitySession.ts +206 -0
  126. package/src/session/projectSessionState.ts +61 -13
  127. package/src/session/refresh.ts +103 -9
  128. package/src/utils/__tests__/oauthPkce.test.ts +51 -0
  129. package/src/utils/commonsDelivery.ts +132 -0
  130. package/src/utils/oauthPkce.ts +56 -3
@@ -58,12 +58,59 @@ export interface PublicKeyCheckResponse {
58
58
  // "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
59
59
  // ===========================================================================
60
60
 
61
+ /**
62
+ * How a "Sign in with Oxy" request finalizes once the approver authorizes it.
63
+ *
64
+ * ONE request (`AuthSession`) serves every delivery surface — popup, push, QR,
65
+ * deep link — so the purpose describes the FINALIZATION, never the transport:
66
+ *
67
+ * - `device_sign_in` — the classic device flow. The initiator exchanges its
68
+ * secret `sessionToken` for the first access token via `claimSessionByToken`.
69
+ * - `oauth_authorization` — the request additionally carries an OAuth binding
70
+ * ({@link CommonsOAuthContext}), so it finalizes into a single-use
71
+ * authorization CODE via {@link OxyServicesAuthMixin.finalizeCommonsOAuth}.
72
+ * The caller still performs the PKCE token exchange itself.
73
+ */
74
+ export type CommonsSignInPurpose = 'device_sign_in' | 'oauth_authorization';
75
+
76
+ /**
77
+ * OAuth binding attached to a "Sign in with Oxy" request so a single
78
+ * `AuthSession` can finalize into a standard OAuth authorization code instead of
79
+ * a device-flow session.
80
+ *
81
+ * Only the minimum request binding is carried here — everything else (the app's
82
+ * name, icon, registered redirect URIs, trust flags) is owned server-side by the
83
+ * `Application` the `clientId` resolves to and is never client-supplied.
84
+ *
85
+ * The PKCE `codeVerifier` NEVER appears here: only its S256 `codeChallenge`
86
+ * crosses the wire, exactly as in the redirect flow. The RP-owned OAuth `state`
87
+ * also stays with the relying party, which validates it locally.
88
+ */
89
+ export interface CommonsOAuthContext {
90
+ /** Exact registered redirect URI the authorization code will be returned to. */
91
+ redirectUri: string;
92
+ /** PKCE `BASE64URL(SHA-256(codeVerifier))` (RFC 7636 §4.2); the verifier stays client-side. */
93
+ codeChallenge: string;
94
+ /** PKCE transformation method. Always `S256` — `plain` is not accepted. */
95
+ codeChallengeMethod: 'S256';
96
+ /** Space-delimited OAuth scope string; the server normalizes and validates it. */
97
+ scope?: string;
98
+ /**
99
+ * Optional delegated account the application will act AS (an organization or
100
+ * project the identity is a member of). The identity approving the request
101
+ * does not change; the server verifies the identity's permission to act as
102
+ * this account before finalizing.
103
+ */
104
+ subjectAccountId?: string;
105
+ }
106
+
61
107
  /**
62
108
  * Handle returned by {@link OxyServicesAuthMixin.startCommonsSignIn} for a
63
109
  * relying-party app initiating a "Sign in with Oxy" flow.
64
110
  *
65
111
  * `sessionToken` is the SECRET, high-entropy device-flow credential — it stays
66
- * on the initiating client, is exchanged once via `claimSessionByToken`, and is
112
+ * on the initiating client, is exchanged once via `claimSessionByToken` (device
113
+ * sign-in) or {@link OxyServicesAuthMixin.finalizeCommonsOAuth} (OAuth), and is
67
114
  * NEVER placed in the QR/deep-link. `authorizeCode` is the PUBLIC handle carried
68
115
  * in `qrPayload`; the approver (Commons) resolves it via
69
116
  * {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
@@ -81,16 +128,79 @@ export interface CommonsSignInHandle {
81
128
  status: string;
82
129
  }
83
130
 
84
- /** Poll result for a "Sign in with Oxy" device-flow session (`GET /auth/session/status`). */
131
+ /**
132
+ * Poll result for a "Sign in with Oxy" device-flow session
133
+ * (`GET /auth/session/status`).
134
+ *
135
+ * The authoritative state machine stays small — `pending → authorized →
136
+ * consumed`, plus `cancelled` / `expired` — and lives in `status`. Delivery
137
+ * PROGRESS (`pushSentAt`, `openedAt`) is carried as timestamps beside it, never
138
+ * as competing statuses, so a progress signal can never be mistaken for an
139
+ * authorization.
140
+ */
85
141
  export interface CommonsSignInStatus {
86
142
  /** True once an approver has authorized the session. */
87
143
  authorized: boolean;
88
- /** The authorized session id (present once `authorized`). */
144
+ /** The authorized session id (present once `authorized` for device sign-in). */
89
145
  sessionId?: string;
90
146
  /** The approving identity's public key (present once `authorized`). */
91
147
  publicKey?: string;
92
148
  /** Lifecycle status (`'pending'` | `'authorized'` | `'cancelled'` | `'expired'`). */
93
149
  status?: string;
150
+ /**
151
+ * How this request finalizes. Unrecognized/missing values degrade to
152
+ * `device_sign_in` so an older API never misroutes an OAuth finalize.
153
+ */
154
+ purpose?: CommonsSignInPurpose;
155
+ /**
156
+ * ISO-8601 timestamp of when the request was pushed to a known Commons
157
+ * installation, or `null` when no push has been sent (including on a server
158
+ * that predates delivery progress). Progress only — it never implies the push
159
+ * was received, opened, or approved.
160
+ */
161
+ pushSentAt: string | null;
162
+ /**
163
+ * ISO-8601 timestamp of when the approval route was opened in Commons, or
164
+ * `null` when it has not been opened. Reported by the approver via
165
+ * {@link OxyServicesAuthMixin.markCommonsApprovalOpened}; it is an
166
+ * un-authenticated progress hint used only to advance the waiting UI, and is
167
+ * NEVER evidence that the request was approved.
168
+ */
169
+ openedAt: string | null;
170
+ }
171
+
172
+ /**
173
+ * Outcome of asking Oxy to deliver a pending sign-in request to the identity's
174
+ * known Commons installations (`POST /auth/session/deliver/:authorizeCode`).
175
+ *
176
+ * `targets: 0` is a NORMAL outcome, not a failure: it simply means no capable
177
+ * Commons installation is registered, so push is not a usable route and the
178
+ * caller shows the QR instead. Feed `targets` into `selectCommonsDelivery`
179
+ * (`utils/commonsDelivery`) rather than branching on it ad hoc.
180
+ */
181
+ export interface CommonsDeliveryResult {
182
+ /** Whether the server dispatched the request to at least one installation. */
183
+ delivered: boolean;
184
+ /** How many eligible Commons installations it was dispatched to (`0` is normal). */
185
+ targets: number;
186
+ }
187
+
188
+ /**
189
+ * The account an application will act AS once the request is approved, when the
190
+ * request delegates to an organization/project rather than the approver's own
191
+ * personal account. Resolved and sanitized server-side from the request's
192
+ * `subjectAccountId`, so it is safe to display in the approval UI.
193
+ *
194
+ * The identity approving stays the identity: Commons renders this as a distinct
195
+ * "will act as" line, never as a change of who is signing.
196
+ */
197
+ export interface CommonsApprovalSubjectAccount {
198
+ /** The delegated account's id. */
199
+ id: string;
200
+ /** The delegated account's handle. */
201
+ username: string;
202
+ /** Optional human-readable name; absent when the account has no real name. */
203
+ displayName?: string;
94
204
  }
95
205
 
96
206
  /**
@@ -113,6 +223,32 @@ export interface CommonsApprovalInfo {
113
223
  * "not verified") by {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
114
224
  */
115
225
  originVerified: boolean;
226
+ /**
227
+ * COARSE, display-only label of the client that STARTED the request
228
+ * (`"Chrome on Windows"`), resolved server-side from the requesting browser —
229
+ * NEVER from the QR payload. Render it verbatim as a secondary line under the
230
+ * origin; it is the whole descriptor the platform has (no raw User-Agent, no
231
+ * IP, no location is ever collected for it).
232
+ *
233
+ * `null` whenever the server has no browser context to describe: native
234
+ * requesters, unidentifiable User-Agents, and any API that predates the field.
235
+ * Omit the line entirely in that case — never substitute a guess.
236
+ */
237
+ requesterLabel: string | null;
238
+ /**
239
+ * How this request finalizes. Always present — an unrecognized or missing
240
+ * server value degrades to `'device_sign_in'`, the behaviour every server has
241
+ * always had, so an older API never makes the approver believe it is granting
242
+ * an OAuth authorization.
243
+ */
244
+ purpose: CommonsSignInPurpose;
245
+ /**
246
+ * The delegated account the application will act as, or `null` when the
247
+ * request is for the approver's own account. Always present — a missing or
248
+ * malformed server value degrades to `null` (fail-safe to "no delegation"),
249
+ * so a partial payload can never imply a broader grant than was requested.
250
+ */
251
+ subjectAccount: CommonsApprovalSubjectAccount | null;
116
252
  /** Server-authoritative expiry (epoch ms or ISO-8601 string from the API). */
117
253
  expiresAt: number | string;
118
254
  /** Session lifecycle status. */
@@ -121,24 +257,114 @@ export interface CommonsApprovalInfo {
121
257
 
122
258
  /**
123
259
  * @internal Raw server response of `GET /auth/session/approve-info/:code`.
124
- * `originVerified` is typed loosely here because older servers may omit it (or
125
- * send a non-boolean); the SDK coerces it to a strict `boolean` when mapping
126
- * into {@link CommonsApprovalInfo}.
260
+ * `originVerified`, `purpose` and `subjectAccount` are typed loosely here
261
+ * because older servers may omit them (or send an unexpected shape); the SDK
262
+ * narrows each one fail-safe when mapping into {@link CommonsApprovalInfo}.
127
263
  */
128
264
  interface CommonsApprovalInfoResponse {
129
265
  application: PublicApplication | null;
130
266
  scopes: string[];
131
267
  boundOrigin?: string;
132
268
  originVerified?: unknown;
269
+ requesterLabel?: unknown;
270
+ purpose?: unknown;
271
+ subjectAccount?: unknown;
133
272
  expiresAt: number | string;
134
273
  status: string;
135
274
  }
136
275
 
276
+ /**
277
+ * @internal Narrow an untrusted `requesterLabel` from the approve-info response.
278
+ *
279
+ * Returns the trimmed label only when the server sent a real, non-empty string;
280
+ * everything else — absent (an API that predates the field), `null` (a native
281
+ * requester the server could not describe), or a non-string — degrades to
282
+ * `null`, so the approval UI drops the line instead of rendering a blank or a
283
+ * coerced value under the app it is about to authorize.
284
+ */
285
+ function parseCommonsRequesterLabel(value: unknown): string | null {
286
+ if (typeof value !== 'string') {
287
+ return null;
288
+ }
289
+ const label = value.trim();
290
+ return label.length > 0 ? label : null;
291
+ }
292
+
293
+ /**
294
+ * @internal Narrow an untrusted `subjectAccount` from the approve-info response.
295
+ *
296
+ * Returns `null` unless the value is an object carrying a non-empty string `id`
297
+ * AND `username` — the two fields the approval UI needs to name the delegated
298
+ * account. A half-populated object is rejected whole rather than rendered with
299
+ * blanks, and `displayName` is only carried through when it is a string.
300
+ */
301
+ function parseCommonsSubjectAccount(value: unknown): CommonsApprovalSubjectAccount | null {
302
+ if (value === null || typeof value !== 'object') {
303
+ return null;
304
+ }
305
+ const raw = value as Record<string, unknown>;
306
+ const { id, username, displayName } = raw;
307
+ if (typeof id !== 'string' || !id || typeof username !== 'string' || !username) {
308
+ return null;
309
+ }
310
+ return {
311
+ id,
312
+ username,
313
+ ...(typeof displayName === 'string' ? { displayName } : {}),
314
+ };
315
+ }
316
+
317
+ /**
318
+ * @internal Narrow an untrusted delivery-progress timestamp from the status
319
+ * response.
320
+ *
321
+ * Returns the ISO-8601 string unchanged when it is a real, parseable instant,
322
+ * and `null` for everything else — absent (an older API that has no delivery
323
+ * progress at all), empty, non-string, or unparseable. Progress is advisory, so
324
+ * degrading to "no progress yet" is always safe; surfacing a garbage timestamp
325
+ * to the waiting UI is not.
326
+ */
327
+ function parseCommonsProgressTimestamp(value: unknown): string | null {
328
+ if (typeof value !== 'string' || !value) {
329
+ return null;
330
+ }
331
+ return Number.isFinite(Date.parse(value)) ? value : null;
332
+ }
333
+
137
334
  /** Result of approving / denying a "Sign in with Oxy" request. */
138
335
  export interface CommonsSignInActionResult {
139
336
  success: boolean;
140
337
  }
141
338
 
339
+ /**
340
+ * Why the approver denied a "Sign in with Oxy" request. A CLOSED set — the deny
341
+ * endpoint is unauthenticated, so it accepts no free-form text:
342
+ *
343
+ * - `'declined'` the approver rejected a request they recognised ("Not now").
344
+ * - `'not_me'` the approver did not start the request ("This wasn't me").
345
+ * The only value that records the denial as suspicious rather
346
+ * than an ordinary cancel, so a UI may only offer it where the
347
+ * user genuinely said so.
348
+ */
349
+ export type CommonsDenyReason = 'declined' | 'not_me';
350
+
351
+ /**
352
+ * Result of finalizing an approved, OAuth-bound "Sign in with Oxy" request.
353
+ *
354
+ * This is an authorization CODE, not a session: the caller still performs the
355
+ * standard PKCE token exchange (`exchangeOAuthCode`) with the `codeVerifier` it
356
+ * has held all along. No access token, refresh token, or device secret is ever
357
+ * produced by finalization.
358
+ */
359
+ export interface CommonsOAuthFinalizeResult {
360
+ /** Single-use OAuth authorization code. */
361
+ code: string;
362
+ /** The exact registered redirect URI the request was bound to. */
363
+ redirectUri: string;
364
+ /** Lifetime of the authorization code, in seconds. */
365
+ expiresIn: number;
366
+ }
367
+
142
368
  /** @internal Response shape of the extended `POST /auth/session/create`. */
143
369
  interface CommonsSessionCreateResponse {
144
370
  authorizeCode: string;
@@ -721,11 +947,16 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
721
947
  // A. Same-device shared-keychain SSO (`signInWithSharedIdentity`): a
722
948
  // sibling native app silently mints its own session from the shared
723
949
  // identity key. No user interaction.
724
- // B. QR / app-to-app handoff: a relying party (`startCommonsSignIn` +
725
- // `pollCommonsSignIn` + the existing `claimSessionByToken`) and the
726
- // approver / Commons (`getCommonsApprovalInfo` + `approveCommonsSignIn`
727
- // / `denyCommonsSignIn`). The approver signs with its PRIMARY local
728
- // key; the RP never sees the private key.
950
+ // B. QR / app-to-app / popup / push handoff: a relying party
951
+ // (`startCommonsSignIn` + `pollCommonsSignIn`, finalized with either
952
+ // `claimSessionByToken` or `finalizeCommonsOAuth`) and the approver /
953
+ // Commons (`getCommonsApprovalInfo` + `approveCommonsSignIn` /
954
+ // `denyCommonsSignIn`). The approver signs with its PRIMARY local key;
955
+ // the RP never sees the private key.
956
+ //
957
+ // ONE request serves every delivery surface. The delivery surface is
958
+ // not part of the model: only the request's purpose is, and it is fixed
959
+ // at creation by whether an OAuth binding was attached.
729
960
  // =======================================================================
730
961
 
731
962
  /**
@@ -793,14 +1024,28 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
793
1024
  * returns the server-issued public `authorizeCode` + ready-to-render
794
1025
  * `qrPayload`. Render the QR (web) / open the deep-link (same-device); the
795
1026
  * approver resolves the code and authorizes. Then poll with
796
- * {@link pollCommonsSignIn} and, on `authorized`, exchange the
797
- * `sessionToken` via the existing `claimSessionByToken`.
1027
+ * {@link pollCommonsSignIn} and finalize.
1028
+ *
1029
+ * ONE request serves every delivery surface, and how it finalizes is decided
1030
+ * here by whether an OAuth binding is attached:
1031
+ * - no `oauth` (the default): a `device_sign_in` request — on `authorized`,
1032
+ * exchange the `sessionToken` via the existing `claimSessionByToken`.
1033
+ * - with `oauth`: an `oauth_authorization` request — on `authorized`, call
1034
+ * {@link finalizeCommonsOAuth} with the same `sessionToken` to mint the
1035
+ * single-use authorization code, then exchange it with PKCE.
798
1036
  *
799
1037
  * @param params.clientId - The RP's registered OAuth client id
800
1038
  * (ApplicationCredential publicKey); required so the server can resolve the
801
1039
  * requesting application's identity.
1040
+ * @param params.oauth - Optional OAuth binding ({@link CommonsOAuthContext}).
1041
+ * Carries only the redirect URI, the PKCE S256 challenge, the requested
1042
+ * scope, and an optional delegated `subjectAccountId` — never the PKCE
1043
+ * verifier, the OAuth `state`, or any token.
802
1044
  */
803
- async startCommonsSignIn(params: { clientId: string }): Promise<CommonsSignInHandle> {
1045
+ async startCommonsSignIn(params: {
1046
+ clientId: string;
1047
+ oauth?: CommonsOAuthContext;
1048
+ }): Promise<CommonsSignInHandle> {
804
1049
  try {
805
1050
  // High-entropy opaque secret token (256-bit hex). Generated client-side
806
1051
  // and held only here; the server stores it but never returns it in the
@@ -811,7 +1056,15 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
811
1056
  const res = await this.makeRequest<CommonsSessionCreateResponse>(
812
1057
  'POST',
813
1058
  '/auth/session/create',
814
- { sessionToken, expiresAt, clientId: params.clientId },
1059
+ {
1060
+ sessionToken,
1061
+ expiresAt,
1062
+ clientId: params.clientId,
1063
+ // Omitted entirely when absent so the device-sign-in body stays
1064
+ // exactly what it has always been (the server reads presence, not a
1065
+ // null, to decide the request's purpose).
1066
+ ...(params.oauth ? { oauth: params.oauth } : {}),
1067
+ },
815
1068
  // Public/pre-session (no bearer): skip the preflight so a stale
816
1069
  // near-expiry token cannot re-enter refreshAccessToken while the
817
1070
  // refresh handler is already in flight (self-await hang).
@@ -834,14 +1087,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
834
1087
  * MECHANISM B (relying party) — poll a device-flow session for approval.
835
1088
  *
836
1089
  * Backstop for the auth socket. On `authorized` (with a `sessionId`), the
837
- * caller exchanges the secret `sessionToken` via the existing
838
- * `claimSessionByToken` to mint the first access token.
1090
+ * caller finalizes: `claimSessionByToken` for a `device_sign_in` request,
1091
+ * {@link finalizeCommonsOAuth} for an `oauth_authorization` one.
1092
+ *
1093
+ * Every field is narrowed fail-safe. `authorized` counts only as a literal
1094
+ * `true`, the identifiers only as non-empty strings, and the delivery
1095
+ * progress timestamps degrade to `null` when absent or unparseable — so a
1096
+ * partial or older-API payload can advance the waiting UI at most, never
1097
+ * make it believe a request was approved.
839
1098
  *
840
1099
  * @param sessionToken - The secret token from {@link startCommonsSignIn}.
841
1100
  */
842
1101
  async pollCommonsSignIn(sessionToken: string): Promise<CommonsSignInStatus> {
843
1102
  try {
844
- return await this.makeRequest<CommonsSignInStatus>(
1103
+ const res = await this.makeRequest<unknown>(
845
1104
  'GET',
846
1105
  `/auth/session/status/${encodeURIComponent(sessionToken)}`,
847
1106
  undefined,
@@ -850,6 +1109,167 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
850
1109
  // refreshAccessToken and await the very promise it runs inside.
851
1110
  { cache: false, retry: false, skipAuth: true }
852
1111
  );
1112
+
1113
+ if (res === null || typeof res !== 'object') {
1114
+ throw new Error('auth/session/status returned an unexpected response shape');
1115
+ }
1116
+ const { authorized, sessionId, publicKey, status, purpose, pushSentAt, openedAt } =
1117
+ res as Record<string, unknown>;
1118
+
1119
+ return {
1120
+ authorized: authorized === true,
1121
+ ...(typeof sessionId === 'string' && sessionId ? { sessionId } : {}),
1122
+ ...(typeof publicKey === 'string' && publicKey ? { publicKey } : {}),
1123
+ ...(typeof status === 'string' && status ? { status } : {}),
1124
+ purpose: purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in',
1125
+ pushSentAt: parseCommonsProgressTimestamp(pushSentAt),
1126
+ openedAt: parseCommonsProgressTimestamp(openedAt),
1127
+ };
1128
+ } catch (error) {
1129
+ throw this.handleError(error);
1130
+ }
1131
+ }
1132
+
1133
+ /**
1134
+ * MECHANISM B (relying party) — ask Oxy to DELIVER a pending sign-in request
1135
+ * to the identity's known Commons installations.
1136
+ *
1137
+ * This is the automatic half of "one intention, one primary action": rather
1138
+ * than offering the user a menu of transports, the caller asks for delivery
1139
+ * and lets the answer pick the route. Pass the returned `targets` to
1140
+ * `selectCommonsDelivery` (`utils/commonsDelivery`) — `targets: 0` means no
1141
+ * capable Commons installation is registered, which is a NORMAL outcome that
1142
+ * resolves to the QR route, not an error to surface.
1143
+ *
1144
+ * **Requires a bearer.** Delivery is only allowed when Oxy already knows the
1145
+ * intended identity from a trusted authenticated context — a request that
1146
+ * merely carries a username or email typed into an unauthenticated browser
1147
+ * must never be able to ring somebody's phone.
1148
+ *
1149
+ * The push it sends carries only `{ type, approvalUrl }` where the URL holds
1150
+ * the public `authorizeCode` — no display data, no secrets. Commons resolves
1151
+ * everything it shows from `getCommonsApprovalInfo`.
1152
+ *
1153
+ * @param authorizeCode - The public code from {@link startCommonsSignIn}.
1154
+ */
1155
+ async deliverCommonsSignIn(authorizeCode: string): Promise<CommonsDeliveryResult> {
1156
+ try {
1157
+ const res = await this.makeRequest<unknown>(
1158
+ 'POST',
1159
+ `/auth/session/deliver/${encodeURIComponent(authorizeCode)}`,
1160
+ undefined,
1161
+ // Bearer REQUIRED (unlike its public siblings): the identity to
1162
+ // deliver to comes from the authenticated caller, never the code.
1163
+ { cache: false },
1164
+ );
1165
+
1166
+ if (res === null || typeof res !== 'object') {
1167
+ throw new Error('auth/session/deliver returned an unexpected response shape');
1168
+ }
1169
+ const { delivered, targets } = res as Record<string, unknown>;
1170
+ // Both fields drive the route choice, so a partial payload is rejected
1171
+ // outright rather than defaulted into a route the server never chose.
1172
+ if (
1173
+ typeof delivered !== 'boolean' ||
1174
+ typeof targets !== 'number' ||
1175
+ !Number.isInteger(targets) ||
1176
+ targets < 0
1177
+ ) {
1178
+ throw new Error('auth/session/deliver returned an incomplete delivery result');
1179
+ }
1180
+
1181
+ return { delivered, targets };
1182
+ } catch (error) {
1183
+ throw this.handleError(error);
1184
+ }
1185
+ }
1186
+
1187
+ /**
1188
+ * MECHANISM B (approver / Commons) — report that the approval route was
1189
+ * OPENED, so the waiting relying party can show "Opened in Commons".
1190
+ *
1191
+ * Progress only. It is idempotent, applies to a `pending` request alone, and
1192
+ * records a timestamp (`openedAt`) — it never approves, authorizes, or
1193
+ * advances the authorization state machine. Public, like the other approver
1194
+ * handles: the approver has only the public `authorizeCode` at this point
1195
+ * and has not yet signed anything.
1196
+ *
1197
+ * Best-effort by nature — a failure here costs the user only a progress
1198
+ * line, so callers are free to ignore a rejection and continue to the
1199
+ * approval screen.
1200
+ *
1201
+ * @param authorizeCode - The public code scanned from the QR / deep-link / push.
1202
+ */
1203
+ async markCommonsApprovalOpened(authorizeCode: string): Promise<void> {
1204
+ try {
1205
+ await this.makeRequest<unknown>(
1206
+ 'POST',
1207
+ `/auth/session/opened/${encodeURIComponent(authorizeCode)}`,
1208
+ undefined,
1209
+ // Public (no bearer) — skip the preflight, exactly like approve-info.
1210
+ { cache: false, skipAuth: true },
1211
+ );
1212
+ } catch (error) {
1213
+ throw this.handleError(error);
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * MECHANISM B (relying party) — finalize an APPROVED, OAuth-bound request
1219
+ * into a single-use OAuth authorization code.
1220
+ *
1221
+ * The OAuth counterpart of `claimSessionByToken`: same secret credential,
1222
+ * same single-use semantics, different output. Call it once the request the
1223
+ * RP started with an `oauth` binding reports `authorized`; the server
1224
+ * atomically mints exactly ONE `AuthCode` bound to the redirect URI, PKCE
1225
+ * challenge, scopes, approving identity, and any delegated subject account
1226
+ * the request was created with. A second call cannot mint another code.
1227
+ *
1228
+ * The result is an authorization CODE, never a token — the caller completes
1229
+ * the flow with the ordinary PKCE exchange (`exchangeOAuthCode`) using the
1230
+ * `codeVerifier` it never sent anywhere. Nothing here is exposed to the
1231
+ * popup: the code travels back through the registered callback, and the
1232
+ * main window owns the verifier.
1233
+ *
1234
+ * Like `claimSessionByToken`, this needs no Authorization header — the
1235
+ * high-entropy SECRET `sessionToken` IS the credential. Never pass the
1236
+ * public `authorizeCode` here; it is the approver's handle, not the
1237
+ * initiator's. Every server-side failure (wrong/expired/already-finalized
1238
+ * request, non-OAuth purpose, missing permission for the delegated account)
1239
+ * surfaces as one generic error, so nothing about the request's state can be
1240
+ * probed from outside.
1241
+ *
1242
+ * @param sessionToken - The secret token from {@link startCommonsSignIn}.
1243
+ */
1244
+ async finalizeCommonsOAuth(sessionToken: string): Promise<CommonsOAuthFinalizeResult> {
1245
+ try {
1246
+ const res = await this.makeRequest<unknown>(
1247
+ 'POST',
1248
+ `/auth/session/finalize/${encodeURIComponent(sessionToken)}`,
1249
+ undefined,
1250
+ // Body-authenticated by the path's secret token (no bearer) — skip the
1251
+ // preflight, exactly like the device-flow claim.
1252
+ { cache: false, skipAuth: true },
1253
+ );
1254
+
1255
+ if (res === null || typeof res !== 'object') {
1256
+ throw new Error('auth/session/finalize returned an unexpected response shape');
1257
+ }
1258
+ const { code, redirectUri, expiresIn } = res as Record<string, unknown>;
1259
+ // All three fields are load-bearing for the exchange that follows, so a
1260
+ // partial payload is rejected outright rather than returned half-parsed.
1261
+ if (
1262
+ typeof code !== 'string' ||
1263
+ !code ||
1264
+ typeof redirectUri !== 'string' ||
1265
+ !redirectUri ||
1266
+ typeof expiresIn !== 'number' ||
1267
+ !Number.isFinite(expiresIn)
1268
+ ) {
1269
+ throw new Error('auth/session/finalize returned an incomplete authorization code');
1270
+ }
1271
+
1272
+ return { code, redirectUri, expiresIn };
853
1273
  } catch (error) {
854
1274
  throw this.handleError(error);
855
1275
  }
@@ -859,9 +1279,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
859
1279
  * MECHANISM B (approver / Commons) — resolve the TRUSTED identity of a
860
1280
  * sign-in request from its public `authorizeCode`.
861
1281
  *
862
- * The returned `application` is resolved server-side and is the only safe
863
- * thing to display in the approval UI — NEVER trust the app/name/origin
864
- * strings carried in the QR payload. Public (no auth required).
1282
+ * The returned `application` and `subjectAccount` are resolved server-side
1283
+ * and are the only safe things to display in the approval UI — NEVER trust
1284
+ * the app/name/origin strings carried in the QR payload. Public (no auth
1285
+ * required).
865
1286
  *
866
1287
  * @param authorizeCode - The public code scanned from the QR / deep-link.
867
1288
  */
@@ -883,6 +1304,17 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
883
1304
  // missing or non-boolean value (older server, malformed response)
884
1305
  // coerces to `false` so a stale server can never imply trust.
885
1306
  originVerified: raw.originVerified === true,
1307
+ // Same discipline: a missing/blank/non-string label degrades to null
1308
+ // (an older API, or a native requester with no browser to describe),
1309
+ // and the approver simply omits the "where from" line.
1310
+ requesterLabel: parseCommonsRequesterLabel(raw.requesterLabel),
1311
+ // Same discipline: only the literal OAuth purpose opts into OAuth
1312
+ // finalization. Anything else — including a server that predates this
1313
+ // field — is the plain device sign-in it has always been.
1314
+ purpose: raw.purpose === 'oauth_authorization' ? 'oauth_authorization' : 'device_sign_in',
1315
+ // A missing/partial delegated account degrades to "no delegation"
1316
+ // rather than a half-rendered "will act as" line.
1317
+ subjectAccount: parseCommonsSubjectAccount(raw.subjectAccount),
886
1318
  expiresAt: raw.expiresAt,
887
1319
  status: raw.status,
888
1320
  };
@@ -944,13 +1376,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
944
1376
  * device-flow session so the RP stops waiting.
945
1377
  *
946
1378
  * @param authorizeCode - The public code being denied.
1379
+ * @param reason - Optional closed-set reason ({@link CommonsDenyReason}).
1380
+ * Pass `'not_me'` ONLY when the user actually reported the request as one
1381
+ * they did not start — the server records it as a suspicious denial rather
1382
+ * than an ordinary cancel. Omitting it sends the exact body this endpoint
1383
+ * has always received.
947
1384
  */
948
- async denyCommonsSignIn(authorizeCode: string): Promise<CommonsSignInActionResult> {
1385
+ async denyCommonsSignIn(
1386
+ authorizeCode: string,
1387
+ reason?: CommonsDenyReason,
1388
+ ): Promise<CommonsSignInActionResult> {
949
1389
  try {
950
1390
  return await this.makeRequest<CommonsSignInActionResult>(
951
1391
  'POST',
952
1392
  `/auth/session/deny/${encodeURIComponent(authorizeCode)}`,
953
- undefined,
1393
+ reason ? { reason } : undefined,
954
1394
  // Public (no auth required) — skip the bearer preflight.
955
1395
  { cache: false, skipAuth: true }
956
1396
  );
@@ -1270,7 +1710,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1270
1710
  const deviceId = (record.deviceId ?? record.device_id) as string | undefined;
1271
1711
  const deviceSecret = (record.deviceSecret ?? record.device_secret) as string | undefined;
1272
1712
  const userRaw = record.user;
1273
- if (!sessionId || !deviceId || !userRaw || typeof userRaw !== 'object') {
1713
+ if (!sessionId || !deviceId || !deviceSecret || !userRaw || typeof userRaw !== 'object') {
1274
1714
  throw new Error('auth/oauth/token returned an incomplete session payload');
1275
1715
  }
1276
1716
  const userObj = userRaw as Record<string, unknown>;