@oxyhq/core 3.10.1 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +189 -0
- package/dist/cjs/index.js +30 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- 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/index.js +6 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/profileLinks.js +52 -0
- 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 +187 -0
- package/dist/esm/index.js +19 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- 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/index.js +6 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/profileLinks.js +49 -0
- 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 +112 -0
- package/dist/types/index.d.ts +10 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +512 -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/index.d.ts +3 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/profileLinks.d.ts +36 -0
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +2 -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__/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 +345 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +225 -0
- package/src/index.ts +55 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.civic.ts +956 -0
- 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/__tests__/OxyServices.civic.test.ts +1097 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +8 -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__/profileLinks.test.ts +126 -0
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/profileLinks.ts +74 -0
- package/src/utils/ssoReturn.ts +2 -2
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity Methods Mixin (self-sovereign identity layer)
|
|
3
|
+
*
|
|
4
|
+
* Provides typed access to Oxy's AtProto/Bluesky-flavoured identity &
|
|
5
|
+
* portability layer:
|
|
6
|
+
* - DID resolution (`did:web:oxy.so:u:<userId>`, derived on demand by the API).
|
|
7
|
+
* - The auth-method ↔ DID verification-method mapping and its reversibility
|
|
8
|
+
* (link/unlink an identity key, link a password) via the existing
|
|
9
|
+
* `/auth/link` surface.
|
|
10
|
+
* - Signed records: clients sign an envelope with their own cryptographic key
|
|
11
|
+
* (`SignatureService.signRecord` + the shared `canonicalize`) and publish it;
|
|
12
|
+
* anyone can fetch and verify it.
|
|
13
|
+
* - The signed data-export ("credible exit") bundle.
|
|
14
|
+
* - Verified-domain badges (prove ownership of `nate.com`).
|
|
15
|
+
*
|
|
16
|
+
* Wire shapes come from `@oxyhq/contracts` (`DidDocument`,
|
|
17
|
+
* `SignedRecordEnvelope`, `AuthMethodsResponse`, `VerifiedDomain`,
|
|
18
|
+
* `DomainVerificationInstructions`, `ExportBundle`) — the single source of truth
|
|
19
|
+
* the API validates its output against, so producer and consumer cannot drift.
|
|
20
|
+
*
|
|
21
|
+
* Identity signing is NATIVE-ONLY: the private key lives in native secure
|
|
22
|
+
* storage, so `linkIdentityKey`, `signRecord`, and `publishRecord` require an
|
|
23
|
+
* on-device identity and throw on web (where `KeyManager.getPublicKey()` is
|
|
24
|
+
* always `null`).
|
|
25
|
+
*/
|
|
26
|
+
import type {
|
|
27
|
+
AuthMethodsResponse,
|
|
28
|
+
DidDocument,
|
|
29
|
+
DomainVerificationInstructions,
|
|
30
|
+
ExportBundle,
|
|
31
|
+
SignedRecordEnvelope,
|
|
32
|
+
VerifiedDomain,
|
|
33
|
+
} from '@oxyhq/contracts';
|
|
34
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
35
|
+
import { KeyManager } from '../crypto/keyManager';
|
|
36
|
+
import { SignatureService } from '../crypto/signatureService';
|
|
37
|
+
import { CACHE_TIMES } from './mixinHelpers';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Registrable apex the Oxy DID method is anchored on. A user's DID is
|
|
41
|
+
* `did:web:<OXY_IDENTITY_APEX>:u:<userId>`, anchored on the stable account id
|
|
42
|
+
* (NOT the keypair).
|
|
43
|
+
*/
|
|
44
|
+
const OXY_IDENTITY_APEX = 'oxy.so';
|
|
45
|
+
|
|
46
|
+
/** Record categories a client may sign and publish. */
|
|
47
|
+
export type IdentityRecordType = SignedRecordEnvelope['type'];
|
|
48
|
+
|
|
49
|
+
/** Auth-method types that can be unlinked via {@link OxyServicesIdentityMixin}. */
|
|
50
|
+
export type UnlinkableAuthMethodType = 'identity' | 'password' | 'google' | 'apple' | 'github';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Result of a link/unlink auth-method mutation (`POST /auth/link`,
|
|
54
|
+
* `DELETE /auth/link/:type`).
|
|
55
|
+
*/
|
|
56
|
+
export interface LinkAuthMethodResult {
|
|
57
|
+
success: boolean;
|
|
58
|
+
message: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Result of publishing a signed record (`POST /identity/records`). Echoes the
|
|
63
|
+
* stored envelope plus the server's verification verdict.
|
|
64
|
+
*/
|
|
65
|
+
export interface PublishRecordResult {
|
|
66
|
+
envelope: SignedRecordEnvelope;
|
|
67
|
+
verified: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Result of verifying a stored record (`GET /identity/records/:userId/:type/verify`).
|
|
72
|
+
* `verified` is the server's verdict; `reason` is present when it is `false`.
|
|
73
|
+
*/
|
|
74
|
+
export interface VerifyRecordResult {
|
|
75
|
+
verified: boolean;
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Result of a successful domain verification (`POST /identity/domains/:domain/verify`). */
|
|
80
|
+
export interface VerifyDomainResult {
|
|
81
|
+
verified: boolean;
|
|
82
|
+
domain: VerifiedDomain;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Result of removing a verified domain (`DELETE /identity/domains/:domain`). */
|
|
86
|
+
export interface RemoveDomainResult {
|
|
87
|
+
success: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive a user's Oxy DID from their stable account id.
|
|
92
|
+
* `did:web:oxy.so:u:<userId>`.
|
|
93
|
+
*/
|
|
94
|
+
export function buildUserDid(userId: string): string {
|
|
95
|
+
return `did:web:${OXY_IDENTITY_APEX}:u:${userId}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
99
|
+
return class extends Base {
|
|
100
|
+
constructor(...args: any[]) {
|
|
101
|
+
super(...(args as [any]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the W3C DID document for any user. The API derives it on demand
|
|
106
|
+
* from the account's `authMethods` + `publicKey` — there is no stored
|
|
107
|
+
* document. Public (no auth required); short-TTL cached.
|
|
108
|
+
*
|
|
109
|
+
* @param userId - The account's Mongo `_id`. URL-encoded into the path.
|
|
110
|
+
*/
|
|
111
|
+
async resolveDid(userId: string): Promise<DidDocument> {
|
|
112
|
+
try {
|
|
113
|
+
return await this.makeRequest<DidDocument>(
|
|
114
|
+
'GET',
|
|
115
|
+
`/u/${encodeURIComponent(userId)}/did.json`,
|
|
116
|
+
undefined,
|
|
117
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
118
|
+
);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw this.handleError(error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The current user's DID (`did:web:oxy.so:u:<userId>`), derived locally from
|
|
126
|
+
* the access token's user id. Throws if no user is authenticated.
|
|
127
|
+
*/
|
|
128
|
+
getMyDid(): string {
|
|
129
|
+
const userId = this.getCurrentUserId();
|
|
130
|
+
if (!userId) {
|
|
131
|
+
throw new Error('No authenticated user — cannot derive DID.');
|
|
132
|
+
}
|
|
133
|
+
return buildUserDid(userId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Resolve the current user's DID document. Requires an authenticated session. */
|
|
137
|
+
async getMyDidDocument(): Promise<DidDocument> {
|
|
138
|
+
const userId = this.getCurrentUserId();
|
|
139
|
+
if (!userId) {
|
|
140
|
+
throw new Error('No authenticated user — cannot resolve DID document.');
|
|
141
|
+
}
|
|
142
|
+
return this.resolveDid(userId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* List the current user's linked authentication methods plus their DID.
|
|
147
|
+
* Each `identity` method carries a `verificationMethodId` linking it to its
|
|
148
|
+
* DID verification-method fragment.
|
|
149
|
+
*/
|
|
150
|
+
async listAuthMethods(): Promise<AuthMethodsResponse> {
|
|
151
|
+
try {
|
|
152
|
+
return await this.makeRequest<AuthMethodsResponse>(
|
|
153
|
+
'GET',
|
|
154
|
+
'/auth/methods',
|
|
155
|
+
undefined,
|
|
156
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
157
|
+
);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw this.handleError(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Link the on-device cryptographic identity to the current account,
|
|
165
|
+
* upgrading it from custodial to self-sovereign. Signs a proof of private
|
|
166
|
+
* key ownership and posts it to `POST /auth/link`.
|
|
167
|
+
*
|
|
168
|
+
* NATIVE-ONLY: requires a stored identity (throws if `KeyManager` has no key
|
|
169
|
+
* or no user is authenticated). The signed payload is
|
|
170
|
+
* `JSON.stringify({ action: 'link_identity', userId, timestamp })` — the
|
|
171
|
+
* exact bytes the server reconstructs and verifies.
|
|
172
|
+
*/
|
|
173
|
+
async linkIdentityKey(): Promise<LinkAuthMethodResult> {
|
|
174
|
+
try {
|
|
175
|
+
const userId = this.getCurrentUserId();
|
|
176
|
+
if (!userId) {
|
|
177
|
+
throw new Error('No authenticated user — sign in before linking an identity key.');
|
|
178
|
+
}
|
|
179
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
180
|
+
if (!publicKey) {
|
|
181
|
+
throw new Error('No identity found on this device. Create or import an identity first.');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const timestamp = Date.now();
|
|
185
|
+
// The signed message MUST match the server's reconstruction byte-for-byte:
|
|
186
|
+
// JSON.stringify with this exact key order (action, userId, timestamp).
|
|
187
|
+
const message = JSON.stringify({ action: 'link_identity', userId, timestamp });
|
|
188
|
+
const signature = await SignatureService.sign(message);
|
|
189
|
+
|
|
190
|
+
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
191
|
+
'POST',
|
|
192
|
+
'/auth/link',
|
|
193
|
+
{ type: 'identity', publicKey, signature, timestamp },
|
|
194
|
+
{ cache: false },
|
|
195
|
+
);
|
|
196
|
+
this._invalidateIdentityCaches(userId);
|
|
197
|
+
return result;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throw this.handleError(error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Link password authentication to the current account. Adds a `password`
|
|
205
|
+
* auth method (does not remove existing methods).
|
|
206
|
+
*
|
|
207
|
+
* @param email - The email to associate with password auth.
|
|
208
|
+
* @param password - The new password (server enforces strength rules).
|
|
209
|
+
*/
|
|
210
|
+
async linkPassword(email: string, password: string): Promise<LinkAuthMethodResult> {
|
|
211
|
+
try {
|
|
212
|
+
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
213
|
+
'POST',
|
|
214
|
+
'/auth/link',
|
|
215
|
+
{ type: 'password', email, password },
|
|
216
|
+
{ cache: false },
|
|
217
|
+
);
|
|
218
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
219
|
+
return result;
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw this.handleError(error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Unlink an authentication method from the current account. The server
|
|
227
|
+
* refuses to remove the last remaining method (the account would become
|
|
228
|
+
* inaccessible). Unlinking `identity` downgrades the account to custodial.
|
|
229
|
+
*
|
|
230
|
+
* @param type - The auth-method type to remove.
|
|
231
|
+
*/
|
|
232
|
+
async unlinkAuthMethod(type: UnlinkableAuthMethodType): Promise<LinkAuthMethodResult> {
|
|
233
|
+
try {
|
|
234
|
+
const result = await this.makeRequest<LinkAuthMethodResult>(
|
|
235
|
+
'DELETE',
|
|
236
|
+
`/auth/link/${encodeURIComponent(type)}`,
|
|
237
|
+
undefined,
|
|
238
|
+
{ cache: false },
|
|
239
|
+
);
|
|
240
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
241
|
+
return result;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw this.handleError(error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Sign a record with the on-device identity key, WITHOUT publishing it.
|
|
249
|
+
* The subject is the current user's DID. NATIVE-ONLY (requires a stored
|
|
250
|
+
* key). Use {@link publishRecord} to sign and store in one step.
|
|
251
|
+
*
|
|
252
|
+
* @param type - The record category.
|
|
253
|
+
* @param record - The arbitrary record payload to attest to.
|
|
254
|
+
*/
|
|
255
|
+
async signRecord(
|
|
256
|
+
type: IdentityRecordType,
|
|
257
|
+
record: Record<string, unknown>,
|
|
258
|
+
): Promise<SignedRecordEnvelope> {
|
|
259
|
+
const subject = this.getMyDid();
|
|
260
|
+
return SignatureService.signRecord(type, subject, record);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Sign a record and publish it to the append-only record store
|
|
265
|
+
* (`POST /identity/records`). NATIVE-ONLY (requires a stored key).
|
|
266
|
+
*
|
|
267
|
+
* @param type - The record category.
|
|
268
|
+
* @param record - The arbitrary record payload to attest to.
|
|
269
|
+
*/
|
|
270
|
+
async publishRecord(
|
|
271
|
+
type: IdentityRecordType,
|
|
272
|
+
record: Record<string, unknown>,
|
|
273
|
+
): Promise<PublishRecordResult> {
|
|
274
|
+
try {
|
|
275
|
+
const envelope = await this.signRecord(type, record);
|
|
276
|
+
return await this.makeRequest<PublishRecordResult>(
|
|
277
|
+
'POST',
|
|
278
|
+
'/identity/records',
|
|
279
|
+
envelope,
|
|
280
|
+
{ cache: false },
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
throw this.handleError(error);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fetch a user's most recent signed record of a given type. Public (no auth
|
|
289
|
+
* required); short-TTL cached.
|
|
290
|
+
*
|
|
291
|
+
* @param userId - The subject account's Mongo `_id`.
|
|
292
|
+
* @param type - The record category to fetch.
|
|
293
|
+
*/
|
|
294
|
+
async getRecord(userId: string, type: IdentityRecordType): Promise<SignedRecordEnvelope> {
|
|
295
|
+
try {
|
|
296
|
+
const res = await this.makeRequest<{ record: SignedRecordEnvelope }>(
|
|
297
|
+
'GET',
|
|
298
|
+
`/identity/records/${encodeURIComponent(userId)}/${encodeURIComponent(type)}`,
|
|
299
|
+
undefined,
|
|
300
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
301
|
+
);
|
|
302
|
+
return res.record;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
throw this.handleError(error);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Ask the server to verify a user's stored record: it recomputes the
|
|
310
|
+
* canonical signing input, checks the signature, and asserts the signing key
|
|
311
|
+
* is a current verification method on the subject's DID.
|
|
312
|
+
*
|
|
313
|
+
* @param userId - The subject account's Mongo `_id`.
|
|
314
|
+
* @param type - The record category to verify.
|
|
315
|
+
*/
|
|
316
|
+
async verifyRecord(userId: string, type: IdentityRecordType): Promise<VerifyRecordResult> {
|
|
317
|
+
try {
|
|
318
|
+
return await this.makeRequest<VerifyRecordResult>(
|
|
319
|
+
'GET',
|
|
320
|
+
`/identity/records/${encodeURIComponent(userId)}/${encodeURIComponent(type)}/verify`,
|
|
321
|
+
undefined,
|
|
322
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
323
|
+
);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw this.handleError(error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Download the current user's signed, open-format data-export bundle
|
|
331
|
+
* (`GET /users/me/export`) — the "credible exit" snapshot. Always carries an
|
|
332
|
+
* Oxy provenance `attestation`; carries an optional client `proof` when the
|
|
333
|
+
* account holds its own key.
|
|
334
|
+
*/
|
|
335
|
+
async exportMyData(): Promise<ExportBundle> {
|
|
336
|
+
try {
|
|
337
|
+
return await this.makeRequest<ExportBundle>(
|
|
338
|
+
'GET',
|
|
339
|
+
'/users/me/export',
|
|
340
|
+
undefined,
|
|
341
|
+
{ cache: false },
|
|
342
|
+
);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
throw this.handleError(error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Start verifying ownership of a domain. Returns the instructions: publish
|
|
350
|
+
* EITHER the DNS-TXT record OR the `/.well-known/oxy-domain` file, then call
|
|
351
|
+
* {@link verifyDomain}.
|
|
352
|
+
*
|
|
353
|
+
* @param domain - The domain to claim (e.g. `nate.com`).
|
|
354
|
+
*/
|
|
355
|
+
async requestDomainVerification(domain: string): Promise<DomainVerificationInstructions> {
|
|
356
|
+
try {
|
|
357
|
+
return await this.makeRequest<DomainVerificationInstructions>(
|
|
358
|
+
'POST',
|
|
359
|
+
'/identity/domains',
|
|
360
|
+
{ domain },
|
|
361
|
+
{ cache: false },
|
|
362
|
+
);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
throw this.handleError(error);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Complete domain verification: the server checks the DNS-TXT record or
|
|
370
|
+
* well-known file and, on success, attaches the domain to the account
|
|
371
|
+
* (surfaced in the DID's `alsoKnownAs` and the user's `verifiedDomains`).
|
|
372
|
+
*
|
|
373
|
+
* @param domain - The domain previously requested via
|
|
374
|
+
* {@link requestDomainVerification}.
|
|
375
|
+
*/
|
|
376
|
+
async verifyDomain(domain: string): Promise<VerifyDomainResult> {
|
|
377
|
+
try {
|
|
378
|
+
const result = await this.makeRequest<VerifyDomainResult>(
|
|
379
|
+
'POST',
|
|
380
|
+
`/identity/domains/${encodeURIComponent(domain)}/verify`,
|
|
381
|
+
undefined,
|
|
382
|
+
{ cache: false },
|
|
383
|
+
);
|
|
384
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
385
|
+
return result;
|
|
386
|
+
} catch (error) {
|
|
387
|
+
throw this.handleError(error);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** List the current user's verified domains. */
|
|
392
|
+
async listDomains(): Promise<VerifiedDomain[]> {
|
|
393
|
+
try {
|
|
394
|
+
const res = await this.makeRequest<{ domains?: VerifiedDomain[] }>(
|
|
395
|
+
'GET',
|
|
396
|
+
'/identity/domains',
|
|
397
|
+
undefined,
|
|
398
|
+
{ cache: true, cacheTTL: CACHE_TIMES.SHORT },
|
|
399
|
+
);
|
|
400
|
+
return res.domains ?? [];
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw this.handleError(error);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Remove a verified domain from the current account.
|
|
408
|
+
* @param domain - The verified domain to remove.
|
|
409
|
+
*/
|
|
410
|
+
async removeDomain(domain: string): Promise<RemoveDomainResult> {
|
|
411
|
+
try {
|
|
412
|
+
const result = await this.makeRequest<RemoveDomainResult>(
|
|
413
|
+
'DELETE',
|
|
414
|
+
`/identity/domains/${encodeURIComponent(domain)}`,
|
|
415
|
+
undefined,
|
|
416
|
+
{ cache: false },
|
|
417
|
+
);
|
|
418
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
419
|
+
return result;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw this.handleError(error);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Bust the cached reads that an identity mutation invalidates: the current
|
|
427
|
+
* user (`/users/me*`), the linked auth-methods list, the verified-domains
|
|
428
|
+
* list, and the user's derived DID document (which embeds auth methods +
|
|
429
|
+
* verified domains, so it goes stale on link/unlink/domain changes).
|
|
430
|
+
*
|
|
431
|
+
* Internal helper (leading underscore); not part of the supported public
|
|
432
|
+
* surface. Public rather than `private` because mixins compose into an
|
|
433
|
+
* exported anonymous class, where TypeScript cannot represent a private
|
|
434
|
+
* member in the emitted declaration file (TS4094).
|
|
435
|
+
*/
|
|
436
|
+
_invalidateIdentityCaches(userId: string | null): void {
|
|
437
|
+
this.clearCacheByPrefix('GET:/users/me');
|
|
438
|
+
this.clearCacheEntry('GET:/auth/methods');
|
|
439
|
+
this.clearCacheEntry('GET:/identity/domains');
|
|
440
|
+
if (userId) {
|
|
441
|
+
this.clearCacheEntry(`GET:/u/${encodeURIComponent(userId)}/did.json`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
@@ -27,6 +27,7 @@ import type { OxyServicesBase } from '../OxyServices.base';
|
|
|
27
27
|
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
28
28
|
import type { UserNameResponse } from '@oxyhq/contracts';
|
|
29
29
|
import { createDebugLogger } from '../shared/utils/debugUtils';
|
|
30
|
+
import { ssoStateKey } from '../utils/ssoBounce';
|
|
30
31
|
|
|
31
32
|
const debug = createDebugLogger('SSO');
|
|
32
33
|
|
|
@@ -67,6 +68,26 @@ export function generateSsoState(): string {
|
|
|
67
68
|
throw new Error('No secure random source available for SSO state generation');
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Read the SSO bounce state stored for the current browser origin, if any.
|
|
73
|
+
*
|
|
74
|
+
* Returns `null` outside a browser (no `window`/`sessionStorage`) or when no
|
|
75
|
+
* state is stored — in which case the caller cannot (and must not) enforce a
|
|
76
|
+
* state match, e.g. native flows or pre-hydration callbacks that already
|
|
77
|
+
* validated the state before this exchange.
|
|
78
|
+
*/
|
|
79
|
+
function getStoredSsoStateForCurrentOrigin(): string | null {
|
|
80
|
+
if (typeof window === 'undefined' || !window.location || !window.sessionStorage) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
return window.sessionStorage.getItem(ssoStateKey(window.location.origin));
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
71
92
|
return class extends Base {
|
|
72
93
|
constructor(...args: any[]) {
|
|
@@ -95,13 +116,21 @@ export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
|
95
116
|
* @param code - The opaque single-use code delivered in the SSO return
|
|
96
117
|
* fragment (see {@link parseSsoReturnFragment}). The central store burns
|
|
97
118
|
* it atomically on exchange.
|
|
119
|
+
* @param state - The state value returned alongside the code. In browsers,
|
|
120
|
+
* when an SSO bounce state is still stored for the current origin, this
|
|
121
|
+
* must match before any token-committing exchange is attempted.
|
|
98
122
|
* @returns The resolved {@link SessionLoginResponse}.
|
|
99
123
|
*/
|
|
100
|
-
public async exchangeSsoCode(code: string): Promise<SessionLoginResponse> {
|
|
124
|
+
public async exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse> {
|
|
101
125
|
if (typeof code !== 'string' || code.length === 0) {
|
|
102
126
|
throw this.handleError(new Error('exchangeSsoCode requires a non-empty code'));
|
|
103
127
|
}
|
|
104
128
|
|
|
129
|
+
const expectedState = getStoredSsoStateForCurrentOrigin();
|
|
130
|
+
if (expectedState !== null && (typeof state !== 'string' || state.length === 0 || state !== expectedState)) {
|
|
131
|
+
throw this.handleError(new Error('SSO exchange state mismatch'));
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/sso/exchange`;
|
|
106
135
|
debug.log('Exchanging SSO code for session...');
|
|
107
136
|
|