@oxyhq/core 3.10.0 → 3.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/AuthManager.js +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +103 -0
- package/dist/cjs/i18n/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/es-ES.json +9 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
- package/dist/cjs/index.js +15 -5
- package/dist/cjs/mixins/OxyServices.assets.js +45 -7
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- package/dist/cjs/mixins/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +102 -0
- package/dist/esm/i18n/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/es-ES.json +9 -0
- package/dist/esm/i18n/locales/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
- package/dist/esm/index.js +10 -2
- package/dist/esm/mixins/OxyServices.assets.js +45 -7
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- package/dist/esm/mixins/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +61 -0
- package/dist/types/index.d.ts +7 -3
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +3 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +125 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +126 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +28 -3
- package/src/mixins/OxyServices.assets.ts +56 -7
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +4 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/fapiAutoDetect.ts +12 -39
- package/src/utils/ssoReturn.ts +2 -2
package/src/index.ts
CHANGED
|
@@ -51,6 +51,13 @@ export type { SilentAuthOptions } from './mixins/OxyServices.silent';
|
|
|
51
51
|
export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
|
|
52
52
|
export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
|
|
53
53
|
export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
54
|
+
// "Sign in with Oxy" — handoff (Workstream C)
|
|
55
|
+
export type {
|
|
56
|
+
CommonsSignInHandle,
|
|
57
|
+
CommonsSignInStatus,
|
|
58
|
+
CommonsApprovalInfo,
|
|
59
|
+
CommonsSignInActionResult,
|
|
60
|
+
} from './mixins/OxyServices.auth';
|
|
54
61
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
55
62
|
export type {
|
|
56
63
|
CreateManagedAccountInput,
|
|
@@ -159,6 +166,23 @@ export type {
|
|
|
159
166
|
ReverseReputationTransactionInput,
|
|
160
167
|
} from './mixins/OxyServices.reputation';
|
|
161
168
|
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
|
|
171
|
+
// verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
|
|
172
|
+
// AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
|
|
173
|
+
// ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
export { buildUserDid } from './mixins/OxyServices.identity';
|
|
176
|
+
export type {
|
|
177
|
+
IdentityRecordType,
|
|
178
|
+
UnlinkableAuthMethodType,
|
|
179
|
+
LinkAuthMethodResult,
|
|
180
|
+
PublishRecordResult,
|
|
181
|
+
VerifyRecordResult,
|
|
182
|
+
VerifyDomainResult,
|
|
183
|
+
RemoveDomainResult,
|
|
184
|
+
} from './mixins/OxyServices.identity';
|
|
185
|
+
|
|
162
186
|
// ---------------------------------------------------------------------------
|
|
163
187
|
// Auth helpers (token refresh, error normalisation, retry policies)
|
|
164
188
|
// ---------------------------------------------------------------------------
|
|
@@ -206,8 +230,9 @@ export {
|
|
|
206
230
|
IdentityPersistError,
|
|
207
231
|
} from './crypto/keyManager';
|
|
208
232
|
export type { KeyPair } from './crypto/keyManager';
|
|
209
|
-
export { SignatureService } from './crypto/signatureService';
|
|
210
|
-
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
233
|
+
export { SignatureService, signedRecordSigningInput } from './crypto/signatureService';
|
|
234
|
+
export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
|
|
235
|
+
export { canonicalize } from './crypto/canonicalJson';
|
|
211
236
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
212
237
|
export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
|
|
213
238
|
|
|
@@ -462,7 +487,7 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
|
|
|
462
487
|
// ---------------------------------------------------------------------------
|
|
463
488
|
// Cross-domain SSO infrastructure
|
|
464
489
|
// ---------------------------------------------------------------------------
|
|
465
|
-
export { autoDetectAuthWebUrl, registrableApex
|
|
490
|
+
export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect';
|
|
466
491
|
|
|
467
492
|
// Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
|
|
468
493
|
export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
|
+
import { isReactNative } from '../utils/platform';
|
|
4
|
+
|
|
5
|
+
interface FileDownloadUrlOptions {
|
|
6
|
+
/** Omit bearer access tokens from generated URLs, even when authenticated. */
|
|
7
|
+
omitToken?: boolean;
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
5
11
|
return class extends Base {
|
|
@@ -44,8 +50,13 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
44
50
|
*
|
|
45
51
|
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
46
52
|
*/
|
|
47
|
-
getFileDownloadUrl(
|
|
48
|
-
|
|
53
|
+
getFileDownloadUrl(
|
|
54
|
+
fileId: string,
|
|
55
|
+
variant?: string,
|
|
56
|
+
expiresIn?: number,
|
|
57
|
+
options: FileDownloadUrlOptions = {}
|
|
58
|
+
): string {
|
|
59
|
+
const token = options.omitToken ? undefined : this.getClient().getAccessToken();
|
|
49
60
|
|
|
50
61
|
// Public case: no auth token and no expiry requested → clean CDN URL.
|
|
51
62
|
// CloudFront serves the public media origin under `${cloudURL}/<id>`.
|
|
@@ -212,10 +223,33 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
212
223
|
} else if (typeof Blob !== 'undefined' && file instanceof Blob) {
|
|
213
224
|
formData.append('file', file, fileName);
|
|
214
225
|
} else if ('uri' in file && typeof (file as RNFileDescriptor).uri === 'string') {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
const descriptor = file as RNFileDescriptor;
|
|
227
|
+
|
|
228
|
+
if (isReactNative()) {
|
|
229
|
+
// React Native file descriptor — RN's FormData handles {uri, type, name} natively.
|
|
230
|
+
// It reads the file from disk during the multipart request — no in-JS Blob
|
|
231
|
+
// conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
|
|
232
|
+
formData.append('file', descriptor as unknown as Blob, fileName);
|
|
233
|
+
} else {
|
|
234
|
+
// Web (browser/Node): the browser's FormData cannot read bytes from a plain
|
|
235
|
+
// { uri } object — it would serialize "[object Object]" and the server would
|
|
236
|
+
// store a 0-byte asset. Materialize the uri into a real Blob first. `fetch`
|
|
237
|
+
// resolves blob:, data:, and http(s): uris on web, so all picker outputs work.
|
|
238
|
+
const res = await fetch(descriptor.uri);
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
throw new Error(`Failed to read file from uri (status ${res.status})`);
|
|
241
|
+
}
|
|
242
|
+
const fetched = await res.blob();
|
|
243
|
+
// Preserve the descriptor's declared MIME type when the fetched blob has none.
|
|
244
|
+
const blob =
|
|
245
|
+
fetched.type === '' && descriptor.type
|
|
246
|
+
? new Blob([fetched], { type: descriptor.type })
|
|
247
|
+
: fetched;
|
|
248
|
+
if (blob.size === 0) {
|
|
249
|
+
throw new Error('Cannot upload an empty file');
|
|
250
|
+
}
|
|
251
|
+
formData.append('file', blob, fileName);
|
|
252
|
+
}
|
|
219
253
|
} else {
|
|
220
254
|
throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
|
|
221
255
|
}
|
|
@@ -452,7 +486,9 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
452
486
|
public async fetchAssetContent(url: string, type: 'text'): Promise<string>;
|
|
453
487
|
public async fetchAssetContent(url: string, type: 'blob'): Promise<Blob>;
|
|
454
488
|
public async fetchAssetContent(url: string, type: 'text' | 'blob') {
|
|
455
|
-
const response = await fetch(url, {
|
|
489
|
+
const response = await fetch(url, {
|
|
490
|
+
credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
|
|
491
|
+
});
|
|
456
492
|
if (!response?.ok) {
|
|
457
493
|
throw new Error(`Failed to fetch asset content (status ${response?.status})`);
|
|
458
494
|
}
|
|
@@ -460,3 +496,16 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
460
496
|
}
|
|
461
497
|
};
|
|
462
498
|
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Only send ambient credentials (cookies) when the asset URL is same-origin with
|
|
502
|
+
* the configured API base. Caller-supplied cross-origin asset URLs must not leak
|
|
503
|
+
* the user's cookies to arbitrary hosts.
|
|
504
|
+
*/
|
|
505
|
+
function shouldSendAssetCredentials(url: string, baseURL: string): boolean {
|
|
506
|
+
try {
|
|
507
|
+
return new URL(url).origin === new URL(baseURL).origin;
|
|
508
|
+
} catch {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -12,11 +12,21 @@ import type {
|
|
|
12
12
|
import type { UserNameResponse } from '@oxyhq/contracts';
|
|
13
13
|
import type { SessionLoginResponse } from '../models/session';
|
|
14
14
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
15
|
+
import type { PublicApplication } from './OxyServices.applications';
|
|
15
16
|
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
17
|
+
import { KeyManager } from '../crypto/keyManager';
|
|
18
|
+
import { SignatureService } from '../crypto/signatureService';
|
|
16
19
|
import { loadNodeCrypto } from '../utils/platformCrypto';
|
|
17
20
|
import { logger } from '../utils/loggerUtils';
|
|
18
21
|
import { normalizeUserIdentity, normalizeUserIdentityOrNull } from '../utils/userIdentity';
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Default lifetime of a "Sign in with Oxy" device-flow session / authorize code.
|
|
25
|
+
* Matches the authorize-code TTL the server enforces (5 minutes). The server's
|
|
26
|
+
* returned `expiresAt` is authoritative; this is only the client-proposed value.
|
|
27
|
+
*/
|
|
28
|
+
const COMMONS_SIGN_IN_EXPIRY_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
20
30
|
export interface ChallengeResponse {
|
|
21
31
|
challenge: string;
|
|
22
32
|
expiresAt: string;
|
|
@@ -64,6 +74,79 @@ export interface PublicKeyCheckResponse {
|
|
|
64
74
|
message: string;
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
// ===========================================================================
|
|
78
|
+
// "Sign in with Oxy" — cross-device QR / app-to-app handoff (Workstream C)
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle returned by {@link OxyServicesAuthMixin.startCommonsSignIn} for a
|
|
83
|
+
* relying-party app initiating a "Sign in with Oxy" flow.
|
|
84
|
+
*
|
|
85
|
+
* `sessionToken` is the SECRET, high-entropy device-flow credential — it stays
|
|
86
|
+
* on the initiating client, is exchanged once via `claimSessionByToken`, and is
|
|
87
|
+
* NEVER placed in the QR/deep-link. `authorizeCode` is the PUBLIC handle carried
|
|
88
|
+
* in `qrPayload`; the approver (Commons) resolves it via
|
|
89
|
+
* {@link OxyServicesAuthMixin.getCommonsApprovalInfo}.
|
|
90
|
+
*/
|
|
91
|
+
export interface CommonsSignInHandle {
|
|
92
|
+
/** Secret device-flow token (held by the initiator; exchanged via `claimSessionByToken`). */
|
|
93
|
+
sessionToken: string;
|
|
94
|
+
/** Public, single-use authorize code carried in the QR / deep-link. */
|
|
95
|
+
authorizeCode: string;
|
|
96
|
+
/** Ready-to-render deep-link / universal-link string (`oxycommons://approve?...`). */
|
|
97
|
+
qrPayload: string;
|
|
98
|
+
/** Server-authoritative expiry (epoch milliseconds). */
|
|
99
|
+
expiresAt: number;
|
|
100
|
+
/** Session lifecycle status as reported by the server (e.g. `'pending'`). */
|
|
101
|
+
status: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Poll result for a "Sign in with Oxy" device-flow session (`GET /auth/session/status`). */
|
|
105
|
+
export interface CommonsSignInStatus {
|
|
106
|
+
/** True once an approver has authorized the session. */
|
|
107
|
+
authorized: boolean;
|
|
108
|
+
/** The authorized session id (present once `authorized`). */
|
|
109
|
+
sessionId?: string;
|
|
110
|
+
/** The approving identity's public key (present once `authorized`). */
|
|
111
|
+
publicKey?: string;
|
|
112
|
+
/** Lifecycle status (`'pending'` | `'authorized'` | `'cancelled'` | `'expired'`). */
|
|
113
|
+
status?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Server-resolved approval context shown by the approver (Commons) before
|
|
118
|
+
* authorizing — the TRUSTED identity of the requesting app, resolved from the
|
|
119
|
+
* `authorizeCode` server-side (never from the QR string).
|
|
120
|
+
*/
|
|
121
|
+
export interface CommonsApprovalInfo {
|
|
122
|
+
/** Sanitized, display-safe identity of the requesting application. */
|
|
123
|
+
application: PublicApplication;
|
|
124
|
+
/** OAuth scopes the application is requesting. */
|
|
125
|
+
scopes: string[];
|
|
126
|
+
/** The origin the session is bound to (the RP web origin), when applicable. */
|
|
127
|
+
boundOrigin?: string;
|
|
128
|
+
/** Server-authoritative expiry (epoch milliseconds). */
|
|
129
|
+
expiresAt: number;
|
|
130
|
+
/** Session lifecycle status. */
|
|
131
|
+
status: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Result of approving / denying a "Sign in with Oxy" request. */
|
|
135
|
+
export interface CommonsSignInActionResult {
|
|
136
|
+
success: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @internal Response shape of the extended `POST /auth/session/create`. */
|
|
140
|
+
interface CommonsSessionCreateResponse {
|
|
141
|
+
authorizeCode: string;
|
|
142
|
+
qrPayload: string;
|
|
143
|
+
status: string;
|
|
144
|
+
/** Optional server-authoritative expiry; falls back to the client-proposed value. */
|
|
145
|
+
expiresAt?: number;
|
|
146
|
+
/** Optional server echo of the session token (the client-supplied value is authoritative). */
|
|
147
|
+
sessionToken?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
67
150
|
export interface ServiceTokenResponse {
|
|
68
151
|
token: string;
|
|
69
152
|
expiresIn: number;
|
|
@@ -247,11 +330,24 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
247
330
|
entry.pending = pending;
|
|
248
331
|
try {
|
|
249
332
|
return await pending;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
// Do not retain unauthenticated cache entries. If the initial
|
|
335
|
+
// /auth/service-token request fails (for example, wrong apiSecret),
|
|
336
|
+
// leaving the pre-seeded empty entry would cause later calls with the
|
|
337
|
+
// real secret for the same apiKey to fail locally as a credential
|
|
338
|
+
// mismatch without ever contacting the server. Keep previously-issued
|
|
339
|
+
// stale tokens on refresh failures, but remove never-authenticated
|
|
340
|
+
// entries.
|
|
341
|
+
const failed = this._serviceTokenCache.get(cacheKey);
|
|
342
|
+
if (failed?.pending === pending && !failed.token) {
|
|
343
|
+
this._serviceTokenCache.delete(cacheKey);
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
250
346
|
} finally {
|
|
251
347
|
// Clear the in-flight slot; the entry itself (with fresh token / expiry)
|
|
252
348
|
// is updated inside _doFetchServiceToken before we land here.
|
|
253
349
|
const settled = this._serviceTokenCache.get(cacheKey);
|
|
254
|
-
if (settled) {
|
|
350
|
+
if (settled?.pending === pending) {
|
|
255
351
|
settled.pending = null;
|
|
256
352
|
}
|
|
257
353
|
}
|
|
@@ -595,6 +691,218 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
595
691
|
}
|
|
596
692
|
}
|
|
597
693
|
|
|
694
|
+
// =======================================================================
|
|
695
|
+
// "Sign in with Oxy" — handoff (Workstream C)
|
|
696
|
+
//
|
|
697
|
+
// Two mechanisms share the same challenge/verify + device-flow primitives:
|
|
698
|
+
// A. Same-device shared-keychain SSO (`signInWithSharedIdentity`): a
|
|
699
|
+
// sibling native app silently mints its own session from the shared
|
|
700
|
+
// identity key. No user interaction.
|
|
701
|
+
// B. QR / app-to-app handoff: a relying party (`startCommonsSignIn` +
|
|
702
|
+
// `pollCommonsSignIn` + the existing `claimSessionByToken`) and the
|
|
703
|
+
// approver / Commons (`getCommonsApprovalInfo` + `approveCommonsSignIn`
|
|
704
|
+
// / `denyCommonsSignIn`). The approver signs with its PRIMARY local
|
|
705
|
+
// key; the RP never sees the private key.
|
|
706
|
+
// =======================================================================
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* MECHANISM A — same-device shared-keychain SSO.
|
|
710
|
+
*
|
|
711
|
+
* Native-only. If this device holds a shared identity (the cross-app
|
|
712
|
+
* `group.so.oxy.shared` keychain key), prove control of it and mint a
|
|
713
|
+
* session: `requestChallenge(sharedPublicKey)` → `signChallengeWithSharedKey`
|
|
714
|
+
* → `verifyChallenge` (which plants the tokens). Returns `null` on web or
|
|
715
|
+
* when no shared identity is present — never throws for the absent-identity
|
|
716
|
+
* case, so a cold-boot caller can fall through to the next step.
|
|
717
|
+
*
|
|
718
|
+
* The cold-boot wiring that CALLS this lives in `OxyContext`
|
|
719
|
+
* (`@oxyhq/services`); this method just performs the exchange.
|
|
720
|
+
*/
|
|
721
|
+
async signInWithSharedIdentity(
|
|
722
|
+
opts: { deviceName?: string; deviceFingerprint?: string } = {}
|
|
723
|
+
): Promise<SessionLoginResponse | null> {
|
|
724
|
+
try {
|
|
725
|
+
// `hasSharedIdentity()` already returns false on web (the shared
|
|
726
|
+
// keychain is native-only), so this short-circuits the web case without
|
|
727
|
+
// a wasted challenge round-trip.
|
|
728
|
+
if (!(await KeyManager.hasSharedIdentity())) {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
const sharedPublicKey = await KeyManager.getSharedPublicKey();
|
|
732
|
+
if (!sharedPublicKey) {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const { challenge } = await this.requestChallenge(sharedPublicKey);
|
|
737
|
+
const signed = await SignatureService.signChallengeWithSharedKey(challenge);
|
|
738
|
+
|
|
739
|
+
// `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
|
|
740
|
+
return await this.verifyChallenge(
|
|
741
|
+
signed.publicKey,
|
|
742
|
+
challenge,
|
|
743
|
+
signed.challenge,
|
|
744
|
+
signed.timestamp,
|
|
745
|
+
opts.deviceName,
|
|
746
|
+
opts.deviceFingerprint,
|
|
747
|
+
);
|
|
748
|
+
} catch (error) {
|
|
749
|
+
throw this.handleError(error);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* MECHANISM B (relying party) — begin a "Sign in with Oxy" handoff.
|
|
755
|
+
*
|
|
756
|
+
* Generates a secret device-flow `sessionToken` client-side (it never
|
|
757
|
+
* appears in the QR), registers it with `POST /auth/session/create`, and
|
|
758
|
+
* returns the server-issued public `authorizeCode` + ready-to-render
|
|
759
|
+
* `qrPayload`. Render the QR (web) / open the deep-link (same-device); the
|
|
760
|
+
* approver resolves the code and authorizes. Then poll with
|
|
761
|
+
* {@link pollCommonsSignIn} and, on `authorized`, exchange the
|
|
762
|
+
* `sessionToken` via the existing `claimSessionByToken`.
|
|
763
|
+
*
|
|
764
|
+
* @param params.clientId - The RP's registered OAuth client id
|
|
765
|
+
* (ApplicationCredential publicKey); required so the server can resolve the
|
|
766
|
+
* requesting application's identity.
|
|
767
|
+
*/
|
|
768
|
+
async startCommonsSignIn(params: { clientId: string }): Promise<CommonsSignInHandle> {
|
|
769
|
+
try {
|
|
770
|
+
// High-entropy opaque secret token (256-bit hex). Generated client-side
|
|
771
|
+
// and held only here; the server stores it but never returns it in the
|
|
772
|
+
// QR. Reuses the platform-safe random generator.
|
|
773
|
+
const sessionToken = await SignatureService.generateChallenge();
|
|
774
|
+
const expiresAt = Date.now() + COMMONS_SIGN_IN_EXPIRY_MS;
|
|
775
|
+
|
|
776
|
+
const res = await this.makeRequest<CommonsSessionCreateResponse>(
|
|
777
|
+
'POST',
|
|
778
|
+
'/auth/session/create',
|
|
779
|
+
{ sessionToken, expiresAt, clientId: params.clientId },
|
|
780
|
+
{ cache: false }
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
sessionToken,
|
|
785
|
+
authorizeCode: res.authorizeCode,
|
|
786
|
+
qrPayload: res.qrPayload,
|
|
787
|
+
expiresAt: res.expiresAt ?? expiresAt,
|
|
788
|
+
status: res.status,
|
|
789
|
+
};
|
|
790
|
+
} catch (error) {
|
|
791
|
+
throw this.handleError(error);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* MECHANISM B (relying party) — poll a device-flow session for approval.
|
|
797
|
+
*
|
|
798
|
+
* Backstop for the auth socket. On `authorized` (with a `sessionId`), the
|
|
799
|
+
* caller exchanges the secret `sessionToken` via the existing
|
|
800
|
+
* `claimSessionByToken` to mint the first access token.
|
|
801
|
+
*
|
|
802
|
+
* @param sessionToken - The secret token from {@link startCommonsSignIn}.
|
|
803
|
+
*/
|
|
804
|
+
async pollCommonsSignIn(sessionToken: string): Promise<CommonsSignInStatus> {
|
|
805
|
+
try {
|
|
806
|
+
return await this.makeRequest<CommonsSignInStatus>(
|
|
807
|
+
'GET',
|
|
808
|
+
`/auth/session/status/${encodeURIComponent(sessionToken)}`,
|
|
809
|
+
undefined,
|
|
810
|
+
{ cache: false, retry: false }
|
|
811
|
+
);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
throw this.handleError(error);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* MECHANISM B (approver / Commons) — resolve the TRUSTED identity of a
|
|
819
|
+
* sign-in request from its public `authorizeCode`.
|
|
820
|
+
*
|
|
821
|
+
* The returned `application` is resolved server-side and is the only safe
|
|
822
|
+
* thing to display in the approval UI — NEVER trust the app/name/origin
|
|
823
|
+
* strings carried in the QR payload. Public (no auth required).
|
|
824
|
+
*
|
|
825
|
+
* @param authorizeCode - The public code scanned from the QR / deep-link.
|
|
826
|
+
*/
|
|
827
|
+
async getCommonsApprovalInfo(authorizeCode: string): Promise<CommonsApprovalInfo> {
|
|
828
|
+
try {
|
|
829
|
+
return await this.makeRequest<CommonsApprovalInfo>(
|
|
830
|
+
'GET',
|
|
831
|
+
`/auth/session/approve-info/${encodeURIComponent(authorizeCode)}`,
|
|
832
|
+
undefined,
|
|
833
|
+
{ cache: false }
|
|
834
|
+
);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
throw this.handleError(error);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* MECHANISM B (approver / Commons) — approve a sign-in request by signing a
|
|
842
|
+
* fresh challenge with the PRIMARY local identity key.
|
|
843
|
+
*
|
|
844
|
+
* Commons holds the user's identity as its primary key (not the shared
|
|
845
|
+
* key), so this uses `signChallenge`. The signed-but-cookieless authorize
|
|
846
|
+
* endpoint resolves the user from the verified signer — the RP that started
|
|
847
|
+
* the flow then claims its session. Native-only (requires a local identity).
|
|
848
|
+
*
|
|
849
|
+
* @param params.authorizeCode - The public code being approved.
|
|
850
|
+
* @param params.deviceName - Optional human-readable device label.
|
|
851
|
+
* @param params.deviceFingerprint - Optional device fingerprint.
|
|
852
|
+
*/
|
|
853
|
+
async approveCommonsSignIn(params: {
|
|
854
|
+
authorizeCode: string;
|
|
855
|
+
deviceName?: string;
|
|
856
|
+
deviceFingerprint?: string;
|
|
857
|
+
}): Promise<CommonsSignInActionResult> {
|
|
858
|
+
try {
|
|
859
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
860
|
+
if (!publicKey) {
|
|
861
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const { challenge } = await this.requestChallenge(publicKey);
|
|
865
|
+
const signed = await SignatureService.signChallenge(challenge);
|
|
866
|
+
|
|
867
|
+
return await this.makeRequest<CommonsSignInActionResult>(
|
|
868
|
+
'POST',
|
|
869
|
+
`/auth/session/authorize-signed/${encodeURIComponent(params.authorizeCode)}`,
|
|
870
|
+
{
|
|
871
|
+
// `signed.challenge` carries the SIGNATURE; `challenge` is the
|
|
872
|
+
// original server-issued challenge string.
|
|
873
|
+
publicKey: signed.publicKey,
|
|
874
|
+
challenge,
|
|
875
|
+
signature: signed.challenge,
|
|
876
|
+
timestamp: signed.timestamp,
|
|
877
|
+
...(params.deviceName ? { deviceName: params.deviceName } : {}),
|
|
878
|
+
...(params.deviceFingerprint ? { deviceFingerprint: params.deviceFingerprint } : {}),
|
|
879
|
+
},
|
|
880
|
+
{ cache: false }
|
|
881
|
+
);
|
|
882
|
+
} catch (error) {
|
|
883
|
+
throw this.handleError(error);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* MECHANISM B (approver / Commons) — deny a sign-in request, cancelling the
|
|
889
|
+
* device-flow session so the RP stops waiting.
|
|
890
|
+
*
|
|
891
|
+
* @param authorizeCode - The public code being denied.
|
|
892
|
+
*/
|
|
893
|
+
async denyCommonsSignIn(authorizeCode: string): Promise<CommonsSignInActionResult> {
|
|
894
|
+
try {
|
|
895
|
+
return await this.makeRequest<CommonsSignInActionResult>(
|
|
896
|
+
'POST',
|
|
897
|
+
`/auth/session/deny/${encodeURIComponent(authorizeCode)}`,
|
|
898
|
+
undefined,
|
|
899
|
+
{ cache: false }
|
|
900
|
+
);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
throw this.handleError(error);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
598
906
|
/**
|
|
599
907
|
* Refresh every device-local refresh-cookie slot in a single round trip
|
|
600
908
|
* (Google-style multi-account rebuild).
|