-
Notifications
You must be signed in to change notification settings - Fork 314
/
Copy pathuserSession.ts
407 lines (378 loc) · 13.2 KB
/
userSession.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
// @ts-ignore
import { Buffer } from '@stacks/common';
import { AppConfig } from './appConfig';
import { SessionOptions } from './sessionData';
import { InstanceDataStore, LocalStorageStore, SessionDataStore } from './sessionStore';
import { decodeToken } from 'jsontokens';
import { verifyAuthResponse } from './verification';
import * as authMessages from './messages';
import {
decryptContent,
encryptContent,
EncryptContentOptions,
isValidPrivateKey,
} from '@stacks/encryption';
import { getAddressFromDID } from './dids';
import {
BLOCKSTACK_DEFAULT_GAIA_HUB_URL,
fetchPrivate,
getGlobalObject,
InvalidStateError,
isLaterVersion,
Logger,
LoginFailedError,
MissingParameterError,
nextHour,
} from '@stacks/common';
import { extractProfile } from '@stacks/profile';
import { AuthScope, DEFAULT_PROFILE } from './constants';
import * as queryString from 'query-string';
import { UserData } from './userData';
import { StacksMainnet } from '@stacks/network';
import { protocolEchoReplyDetection } from './protocolEchoDetection';
/**
*
* Represents an instance of a signed in user for a particular app.
*
* A signed in user has access to two major pieces of information
* about the user, the user's private key for that app and the location
* of the user's gaia storage bucket for the app.
*
* A user can be signed in either directly through the interactive
* sign in process or by directly providing the app private key.
*
*
*/
export class UserSession {
appConfig: AppConfig;
store: SessionDataStore;
/**
* Creates a UserSession object
*
* @param options
*/
constructor(options?: {
appConfig?: AppConfig;
sessionStore?: SessionDataStore;
sessionOptions?: SessionOptions;
}) {
let runningInBrowser = true;
if (typeof window === 'undefined' && typeof self === 'undefined') {
// Logger.debug('UserSession: not running in browser')
runningInBrowser = false;
}
if (options && options.appConfig) {
this.appConfig = options.appConfig;
} else if (runningInBrowser) {
this.appConfig = new AppConfig();
} else {
throw new MissingParameterError('You need to specify options.appConfig');
}
if (options && options.sessionStore) {
this.store = options.sessionStore;
} else if (runningInBrowser) {
if (options) {
this.store = new LocalStorageStore(options.sessionOptions);
} else {
this.store = new LocalStorageStore();
}
} else if (options) {
this.store = new InstanceDataStore(options.sessionOptions);
} else {
this.store = new InstanceDataStore();
}
}
/**
* Generates an authentication request that can be sent to the Blockstack
* browser for the user to approve sign in. This authentication request can
* then be used for sign in by passing it to the [[redirectToSignInWithAuthRequest]]
* method.
*
* *Note*: This method should only be used if you want to use a customized authentication
* flow. Typically, you'd use [[redirectToSignIn]] which is the default sign in method.
*
* @param transitKey A HEX encoded transit private key.
* @param redirectURI Location to redirect the user to after sign in approval.
* @param manifestURI Location of this app's manifest file.
* @param scopes The permissions this app is requesting. The default is `store_write`.
* @param appDomain The origin of the app.
* @param expiresAt The time at which this request is no longer valid.
* @param extraParams Any extra parameters to pass to the authenticator. Use this to
* pass options that aren't part of the Blockstack authentication specification,
* but might be supported by special authenticators.
*
* @returns {String} the authentication request
*/
makeAuthRequest(
transitKey?: string,
redirectURI?: string,
manifestURI?: string,
scopes?: (AuthScope | string)[],
appDomain?: string,
expiresAt: number = nextHour().getTime(),
extraParams: any = {}
): string {
const appConfig = this.appConfig;
if (!appConfig) {
throw new InvalidStateError('Missing AppConfig');
}
transitKey = transitKey || this.generateAndStoreTransitKey();
redirectURI = redirectURI || appConfig.redirectURI();
manifestURI = manifestURI || appConfig.manifestURI();
scopes = scopes || appConfig.scopes;
appDomain = appDomain || appConfig.appDomain;
return authMessages.makeAuthRequest(
transitKey,
redirectURI,
manifestURI,
scopes,
appDomain,
expiresAt,
extraParams
);
}
/**
* Generates a ECDSA keypair to
* use as the ephemeral app transit private key
* and store in the session.
*
* @returns {String} the hex encoded private key
*
*/
generateAndStoreTransitKey(): string {
const sessionData = this.store.getSessionData();
const transitKey = authMessages.generateTransitKey();
sessionData.transitKey = transitKey;
this.store.setSessionData(sessionData);
return transitKey;
}
/**
* Retrieve the authentication token from the URL query
* @return {String} the authentication token if it exists otherwise `null`
*/
getAuthResponseToken(): string {
const search = getGlobalObject('location', {
throwIfUnavailable: true,
usageDesc: 'getAuthResponseToken',
})?.search;
if (search) {
const queryDict = queryString.parse(search);
return queryDict.authResponse ? (queryDict.authResponse as string) : '';
}
return '';
}
/**
* Check if there is a authentication request that hasn't been handled.
*
* Also checks for a protocol echo reply (which if detected then the page
* will be automatically redirected after this call).
*
* @return {Boolean} `true` if there is a pending sign in, otherwise `false`
*/
isSignInPending() {
try {
const isProtocolEcho = protocolEchoReplyDetection();
if (isProtocolEcho) {
Logger.info(
'protocolEchoReply detected from isSignInPending call, the page is about to redirect.'
);
return true;
}
} catch (error) {
Logger.error(`Error checking for protocol echo reply isSignInPending: ${error}`);
}
return !!this.getAuthResponseToken();
}
/**
* Check if a user is currently signed in.
*
* @returns {Boolean} `true` if the user is signed in, `false` if not.
*/
isUserSignedIn() {
return !!this.store.getSessionData().userData;
}
/**
* Try to process any pending sign in request by returning a `Promise` that resolves
* to the user data object if the sign in succeeds.
*
* @param {String} authResponseToken - the signed authentication response token
* @returns {Promise} that resolves to the user data object if successful and rejects
* if handling the sign in request fails or there was no pending sign in request.
*/
async handlePendingSignIn(
authResponseToken: string = this.getAuthResponseToken()
): Promise<UserData> {
const sessionData = this.store.getSessionData();
if (sessionData.userData) {
throw new LoginFailedError('Existing user session found.');
}
const transitKey = this.store.getSessionData().transitKey;
// let nameLookupURL;
let coreNode = this.appConfig && this.appConfig.coreNode;
if (!coreNode) {
const network = new StacksMainnet();
coreNode = network.bnsLookupUrl;
}
const tokenPayload = decodeToken(authResponseToken).payload;
if (typeof tokenPayload === 'string') {
throw new Error('Unexpected token payload type of string');
}
const isValid = await verifyAuthResponse(authResponseToken);
if (!isValid) {
throw new LoginFailedError('Invalid authentication response.');
}
// TODO: real version handling
let appPrivateKey: string = tokenPayload.private_key as string;
let coreSessionToken: string = tokenPayload.core_token as string;
if (isLaterVersion(tokenPayload.version as string, '1.1.0')) {
if (transitKey !== undefined && transitKey != null) {
if (tokenPayload.private_key !== undefined && tokenPayload.private_key !== null) {
try {
appPrivateKey = (await authMessages.decryptPrivateKey(
transitKey,
tokenPayload.private_key as string
)) as string;
} catch (e) {
Logger.warn('Failed decryption of appPrivateKey, will try to use as given');
if (!isValidPrivateKey(tokenPayload.private_key as string)) {
throw new LoginFailedError(
'Failed decrypting appPrivateKey. Usually means' +
' that the transit key has changed during login.'
);
}
}
}
if (coreSessionToken !== undefined && coreSessionToken !== null) {
try {
coreSessionToken = (await authMessages.decryptPrivateKey(
transitKey,
coreSessionToken
)) as string;
} catch (e) {
Logger.info('Failed decryption of coreSessionToken, will try to use as given');
}
}
} else {
throw new LoginFailedError(
'Authenticating with protocol > 1.1.0 requires transit' + ' key, and none found.'
);
}
}
let hubUrl = BLOCKSTACK_DEFAULT_GAIA_HUB_URL;
let gaiaAssociationToken: string;
if (
isLaterVersion(tokenPayload.version as string, '1.2.0') &&
tokenPayload.hubUrl !== null &&
tokenPayload.hubUrl !== undefined
) {
hubUrl = tokenPayload.hubUrl as string;
}
if (
isLaterVersion(tokenPayload.version as string, '1.3.0') &&
tokenPayload.associationToken !== null &&
tokenPayload.associationToken !== undefined
) {
gaiaAssociationToken = tokenPayload.associationToken as string;
}
const userData: UserData = {
profile: tokenPayload.profile,
email: tokenPayload.email as string,
decentralizedID: tokenPayload.iss,
identityAddress: getAddressFromDID(tokenPayload.iss),
appPrivateKey,
coreSessionToken,
authResponseToken,
hubUrl,
appPrivateKeyFromWalletSalt: tokenPayload.appPrivateKeyFromWalletSalt as string,
coreNode: tokenPayload.blockstackAPIUrl as string,
// @ts-expect-error
gaiaAssociationToken,
};
const profileURL = tokenPayload.profile_url as string;
if (!userData.profile && profileURL) {
const response = await fetchPrivate(profileURL);
if (!response.ok) {
// return blank profile if we fail to fetch
userData.profile = Object.assign({}, DEFAULT_PROFILE);
} else {
const responseText = await response.text();
const wrappedProfile = JSON.parse(responseText);
userData.profile = extractProfile(wrappedProfile[0].token);
}
} else {
userData.profile = tokenPayload.profile;
}
sessionData.userData = userData;
this.store.setSessionData(sessionData);
return userData;
}
/**
* Retrieves the user data object. The user's profile is stored in the key [[Profile]].
*
* @returns {Object} User data object.
*/
loadUserData() {
const userData = this.store.getSessionData().userData;
if (!userData) {
throw new InvalidStateError('No user data found. Did the user sign in?');
}
return userData;
}
/**
* Encrypts the data provided with the app public key.
* @param {String|Buffer} content the data to encrypt
* @param options
* @param {String} options.publicKey the hex string of the ECDSA public
* key to use for encryption. If not provided, will use user's appPrivateKey.
*
* @returns {String} Stringified ciphertext object
*/
encryptContent(content: string | Buffer, options?: EncryptContentOptions): Promise<string> {
const opts = Object.assign({}, options);
if (!opts.privateKey) {
opts.privateKey = this.loadUserData().appPrivateKey;
}
return encryptContent(content, opts);
}
/**
* Decrypts data encrypted with `encryptContent` with the
* transit private key.
* @param {String|Buffer} content - encrypted content.
* @param options
* @param {String} options.privateKey - The hex string of the ECDSA private
* key to use for decryption. If not provided, will use user's appPrivateKey.
* @returns {String|Buffer} decrypted content.
*/
decryptContent(content: string, options?: { privateKey?: string }): Promise<Buffer | string> {
const opts = Object.assign({}, options);
if (!opts.privateKey) {
opts.privateKey = this.loadUserData().appPrivateKey;
}
return decryptContent(content, opts);
}
/**
* Sign the user out and optionally redirect to given location.
* @param redirectURL
* Location to redirect user to after sign out.
* Only used in environments with `window` available
*/
signUserOut(
redirectURL?: string
// TODO: this is not used?
// caller?: UserSession
) {
this.store.deleteSessionData();
if (redirectURL) {
if (typeof location !== 'undefined' && location.href) {
location.href = redirectURL;
}
// TODO: Invalid left-hand side in assignment expression
// // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// // @ts-ignore
// getGlobalObject('location', {
// throwIfUnavailable: true,
// usageDesc: 'signUserOut',
// })?.href = redirectURL;
}
}
}