@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/hubSync.js +1 -1
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/hubSync.js +1 -1
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/hubSync.test.ts +51 -0
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/hubSync.ts +1 -1
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- 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
|
|
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
|
-
/**
|
|
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`
|
|
125
|
-
*
|
|
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
|
|
725
|
-
// `
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
//
|
|
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
|
|
797
|
-
*
|
|
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: {
|
|
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
|
-
{
|
|
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
|
|
838
|
-
*
|
|
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
|
-
|
|
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`
|
|
863
|
-
*
|
|
864
|
-
* strings carried in the QR payload. Public (no auth
|
|
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(
|
|
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>;
|