Skip to content

Commit ee784cc

Browse files
committed
feat: implement fetch middleware for network requests, API key middleware, and http origin middleware
1 parent 640a81c commit ee784cc

File tree

26 files changed

+372
-173
lines changed

26 files changed

+372
-173
lines changed

packages/auth/src/profile.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { resolveZoneFileToProfile } from '@stacks/profile';
2-
import { fetchPrivate } from '@stacks/common';
32
import { StacksMainnet, StacksNetwork, StacksNetworkName } from '@stacks/network';
43

54
export interface ProfileLookupOptions {
@@ -31,13 +30,13 @@ export function lookupProfile(lookupOptions: ProfileLookupOptions): Promise<Reco
3130
let lookupPromise;
3231
if (options.zoneFileLookupURL) {
3332
const url = `${options.zoneFileLookupURL.replace(/\/$/, '')}/${options.username}`;
34-
lookupPromise = fetchPrivate(url).then(response => response.json());
33+
lookupPromise = network.fetchFn(url).then(response => response.json());
3534
} else {
3635
lookupPromise = network.getNameInfo(options.username);
3736
}
3837
return lookupPromise.then((responseJSON: any) => {
3938
if (responseJSON.hasOwnProperty('zonefile') && responseJSON.hasOwnProperty('address')) {
40-
return resolveZoneFileToProfile(responseJSON.zonefile, responseJSON.address);
39+
return resolveZoneFileToProfile(responseJSON.zonefile, responseJSON.address, network.fetchFn);
4140
} else {
4241
throw new Error(
4342
'Invalid zonefile lookup response: did not contain `address`' + ' or `zonefile` field'

packages/auth/src/provider.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as queryString from 'query-string';
22
import { decodeToken } from 'jsontokens';
3-
import { BLOCKSTACK_HANDLER, getGlobalObject, fetchPrivate } from '@stacks/common';
3+
import { BLOCKSTACK_HANDLER, getGlobalObject, getDefaultFetchFn, FetchFn } from '@stacks/common';
44

55
/**
66
* Retrieves the authentication request from the query string
@@ -36,7 +36,10 @@ export function getAuthRequestFromURL() {
3636
* @private
3737
* @ignore
3838
*/
39-
export async function fetchAppManifest(authRequest: string): Promise<any> {
39+
export async function fetchAppManifest(
40+
authRequest: string,
41+
fetchFn: FetchFn = getDefaultFetchFn()
42+
): Promise<any> {
4043
if (!authRequest) {
4144
throw new Error('Invalid auth request');
4245
}
@@ -47,7 +50,7 @@ export async function fetchAppManifest(authRequest: string): Promise<any> {
4750
const manifestURI = payload.manifest_uri as string;
4851
try {
4952
// Logger.debug(`Fetching manifest from ${manifestURI}`)
50-
const response = await fetchPrivate(manifestURI);
53+
const response = await fetchFn(manifestURI);
5154
const responseText = await response.text();
5255
const responseJSON = JSON.parse(responseText);
5356
return { ...responseJSON, manifestURI };

packages/auth/src/userSession.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-ignore
2-
import { Buffer } from '@stacks/common';
2+
import { Buffer, FetchFn, getDefaultFetchFn } from '@stacks/common';
33
import { AppConfig } from './appConfig';
44
import { SessionOptions } from './sessionData';
55
import { InstanceDataStore, LocalStorageStore, SessionDataStore } from './sessionStore';
@@ -15,7 +15,6 @@ import {
1515
import { getAddressFromDID } from './dids';
1616
import {
1717
BLOCKSTACK_DEFAULT_GAIA_HUB_URL,
18-
fetchPrivate,
1918
getGlobalObject,
2019
InvalidStateError,
2120
isLaterVersion,
@@ -214,7 +213,8 @@ export class UserSession {
214213
* if handling the sign in request fails or there was no pending sign in request.
215214
*/
216215
async handlePendingSignIn(
217-
authResponseToken: string = this.getAuthResponseToken()
216+
authResponseToken: string = this.getAuthResponseToken(),
217+
fetchFn: FetchFn = getDefaultFetchFn()
218218
): Promise<UserData> {
219219
const sessionData = this.store.getSessionData();
220220

@@ -312,7 +312,7 @@ export class UserSession {
312312
};
313313
const profileURL = tokenPayload.profile_url as string;
314314
if (!userData.profile && profileURL) {
315-
const response = await fetchPrivate(profileURL);
315+
const response = await fetchFn(profileURL);
316316
if (!response.ok) {
317317
// return blank profile if we fail to fetch
318318
userData.profile = Object.assign({}, DEFAULT_PROFILE);

packages/cli/src/network.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import blockstack from 'blockstack';
22
import * as bitcoin from 'bitcoinjs-lib';
33
import BN from 'bn.js';
4-
import fetch from 'node-fetch';
54

65
import { CLI_CONFIG_TYPE } from './argparse';
76

87
import { BlockstackNetwork } from 'blockstack/lib/network';
8+
import { FetchFn, getDefaultFetchFn } from '@stacks/common';
99

1010
export interface CLI_NETWORK_OPTS {
1111
consensusHash: string | null;
@@ -187,15 +187,16 @@ export class CLINetworkAdapter {
187187
getNamespaceBurnAddress(
188188
namespace: string,
189189
useCLI: boolean = true,
190-
receiveFeesPeriod: number = -1
190+
receiveFeesPeriod: number = -1,
191+
fetchFn: FetchFn = getDefaultFetchFn()
191192
): Promise<string> {
192193
// override with CLI option
193194
if (this.namespaceBurnAddress && useCLI) {
194195
return new Promise((resolve: any) => resolve(this.namespaceBurnAddress));
195196
}
196197

197198
return Promise.all([
198-
fetch(`${this.legacyNetwork.blockstackAPIUrl}/v1/namespaces/${namespace}`),
199+
fetchFn(`${this.legacyNetwork.blockstackAPIUrl}/v1/namespaces/${namespace}`),
199200
this.legacyNetwork.getBlockHeight(),
200201
])
201202
.then(([resp, blockHeight]: [any, number]) => {
@@ -245,10 +246,10 @@ export class CLINetworkAdapter {
245246
});
246247
}
247248

248-
getBlockchainNameRecord(name: string): Promise<any> {
249+
getBlockchainNameRecord(name: string, fetchFn: FetchFn = getDefaultFetchFn()): Promise<any> {
249250
// TODO: send to blockstack.js
250251
const url = `${this.legacyNetwork.blockstackAPIUrl}/v1/blockchains/bitcoin/names/${name}`;
251-
return fetch(url)
252+
return fetchFn(url)
252253
.then(resp => {
253254
if (resp.status !== 200) {
254255
throw new Error(`Bad response status: ${resp.status}`);
@@ -268,10 +269,14 @@ export class CLINetworkAdapter {
268269
});
269270
}
270271

271-
getNameHistory(name: string, page: number): Promise<Record<string, any[]>> {
272+
getNameHistory(
273+
name: string,
274+
page: number,
275+
fetchFn: FetchFn = getDefaultFetchFn()
276+
): Promise<Record<string, any[]>> {
272277
// TODO: send to blockstack.js
273278
const url = `${this.legacyNetwork.blockstackAPIUrl}/v1/names/${name}/history?page=${page}`;
274-
return fetch(url)
279+
return fetchFn(url)
275280
.then(resp => {
276281
if (resp.status !== 200) {
277282
throw new Error(`Bad response status: ${resp.status}`);

packages/common/src/fetchUtil.ts

Lines changed: 183 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,188 @@
11
import 'cross-fetch/polyfill';
22

3-
/** @ignore */
4-
export async function fetchPrivate(input: RequestInfo, init?: RequestInit): Promise<Response> {
5-
const defaultFetchOpts: RequestInit = {
6-
referrer: 'no-referrer',
7-
referrerPolicy: 'no-referrer',
3+
export type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
4+
5+
export interface RequestContext {
6+
fetch: FetchFn;
7+
url: string;
8+
init: RequestInit;
9+
}
10+
11+
export interface ResponseContext {
12+
fetch: FetchFn;
13+
url: string;
14+
init: RequestInit;
15+
response: Response;
16+
}
17+
18+
export interface FetchParams {
19+
url: string;
20+
init: RequestInit;
21+
}
22+
23+
export interface FetchMiddleware {
24+
pre?: (context: RequestContext) => PromiseLike<FetchParams | void> | FetchParams | void;
25+
post?: (context: ResponseContext) => Promise<Response | void> | Response | void;
26+
}
27+
28+
// TODO: make the value a promise so that multiple re-auth requests are not running in parallel
29+
// TODO: make the storage interface configurable
30+
// in-memory session auth data, keyed by the endpoint host
31+
const sessionAuthData = new Map<string, { authKey: string }>();
32+
33+
export interface ApiSessionAuthMiddlewareOpts {
34+
/** The middleware / API key header will only be added to requests matching this host. */
35+
host?: RegExp | string;
36+
/** The http header name used for specifying the API key value. */
37+
httpHeader?: string;
38+
authPath: string;
39+
authRequestMetadata: Record<string, string>;
40+
}
41+
42+
export function getApiSessionAuthMiddleware({
43+
host = /(.*)api(.*)\.stacks\.co$/i,
44+
httpHeader = 'x-api-key',
45+
authPath = '/request_key',
46+
authRequestMetadata = {},
47+
}: ApiSessionAuthMiddlewareOpts): FetchMiddleware {
48+
const authMiddleware: FetchMiddleware = {
49+
pre: context => {
50+
const reqUrl = new URL(context.url);
51+
let hostMatches = false;
52+
if (typeof host === 'string') {
53+
hostMatches = host === reqUrl.host;
54+
} else {
55+
hostMatches = !!host.exec(reqUrl.host);
56+
}
57+
const authData = sessionAuthData.get(reqUrl.host);
58+
if (hostMatches && authData) {
59+
const headers = new Headers(context.init.headers);
60+
headers.set(httpHeader, authData.authKey);
61+
context.init.headers = headers;
62+
}
63+
},
64+
post: async context => {
65+
const reqUrl = new URL(context.url);
66+
let hostMatches = false;
67+
if (typeof host === 'string') {
68+
hostMatches = host === reqUrl.host;
69+
} else {
70+
hostMatches = !!host.exec(reqUrl.host);
71+
}
72+
// if request is for configured host, and response was `401 Unauthorized`,
73+
// then request auth key and retry request.
74+
if (hostMatches && context.response.status === 401) {
75+
const authEndpoint = new URL(reqUrl.origin);
76+
authEndpoint.pathname = authPath;
77+
const authReq = await context.fetch(authEndpoint.toString(), {
78+
method: 'POST',
79+
headers: {
80+
'Content-Type': 'application/json',
81+
Accept: 'application/json',
82+
},
83+
body: JSON.stringify(authRequestMetadata),
84+
});
85+
const authResponseBody = await authReq.text();
86+
if (authReq.ok) {
87+
const authResp: { api_key: string } = JSON.parse(authResponseBody);
88+
sessionAuthData.set(reqUrl.host, { authKey: authResp.api_key });
89+
return context.fetch(context.url, context.init);
90+
} else {
91+
throw new Error(`Error fetching API auth key: ${authReq.status}: ${authResponseBody}`);
92+
}
93+
} else {
94+
return context.response;
95+
}
96+
},
97+
};
98+
return authMiddleware;
99+
}
100+
101+
export interface ApiKeyMiddlewareOpts {
102+
/** The middleware / API key header will only be added to requests matching this host. */
103+
host?: RegExp | string;
104+
/** The http header name used for specifying the API key value. */
105+
httpHeader?: string;
106+
/** The API key string to specify as an http header value. */
107+
apiKey: string;
108+
}
109+
110+
export function createApiKeyMiddleware({
111+
apiKey,
112+
host = /(.*)api(.*)\.stacks\.co$/i,
113+
httpHeader = 'x-api-key',
114+
}: ApiKeyMiddlewareOpts): FetchMiddleware {
115+
return {
116+
pre: context => {
117+
const reqUrl = new URL(context.url);
118+
let hostMatches = false;
119+
if (typeof host === 'string') {
120+
hostMatches = host === reqUrl.host;
121+
} else {
122+
hostMatches = !!host.exec(reqUrl.host);
123+
}
124+
if (hostMatches) {
125+
const headers = new Headers(context.init.headers);
126+
headers.set(httpHeader, apiKey);
127+
context.init.headers = headers;
128+
}
129+
},
8130
};
9-
const fetchOpts = Object.assign(defaultFetchOpts, init);
10-
const fetchResult = await fetch(input, fetchOpts);
11-
return fetchResult;
12-
}
13-
14-
export async function soFetch(
15-
fetchLib: typeof fetch,
16-
input: RequestInfo,
17-
init?: RequestInit
18-
): Promise<Response> {
19-
const defaultFetchOpts: RequestInit = {
20-
referrer: 'no-referrer',
21-
referrerPolicy: 'no-referrer',
131+
}
132+
133+
function getDefaultMiddleware(): FetchMiddleware[] {
134+
const setOriginMiddleware: FetchMiddleware = {
135+
pre: context => {
136+
// Send only the origin in the Referer header. For example, a document
137+
// at https://example.com/page.html will send the referrer https://example.com/
138+
context.init.referrerPolicy = 'origin';
139+
},
140+
};
141+
return [setOriginMiddleware];
142+
}
143+
144+
export function getDefaultFetchFn(fetchLib: FetchFn, ...middleware: FetchMiddleware[]): FetchFn;
145+
export function getDefaultFetchFn(...middleware: FetchMiddleware[]): FetchFn;
146+
export function getDefaultFetchFn(...args: any[]): FetchFn {
147+
let fetchLib: FetchFn = fetch;
148+
let middlewareOpt: FetchMiddleware[] = [];
149+
if (args.length > 0) {
150+
if (typeof args[0] === 'function') {
151+
fetchLib = args.shift();
152+
}
153+
}
154+
if (args.length > 0) {
155+
middlewareOpt = args;
156+
}
157+
const middlewares = [...getDefaultMiddleware(), ...middlewareOpt];
158+
const fetchFn = async (url: string, init?: RequestInit | undefined): Promise<Response> => {
159+
let fetchParams = { url, init: init || {} };
160+
for (const middleware of middlewares) {
161+
if (middleware.pre) {
162+
const result = await Promise.resolve(
163+
middleware.pre({
164+
fetch: fetchLib,
165+
...fetchParams,
166+
})
167+
);
168+
fetchParams = result ?? fetchParams;
169+
}
170+
}
171+
let response = await fetchLib(fetchParams.url, fetchParams.init);
172+
for (const middleware of middlewares) {
173+
if (middleware.post) {
174+
const result = await Promise.resolve(
175+
middleware.post({
176+
fetch: fetchLib,
177+
url: fetchParams.url,
178+
init: fetchParams.init,
179+
response: response.clone(),
180+
})
181+
);
182+
response = result ?? response;
183+
}
184+
}
185+
return response;
22186
};
23-
const fetchOpts = Object.assign(defaultFetchOpts, init);
24-
const fetchResult = await fetchLib(input, fetchOpts);
25-
return fetchResult;
187+
return fetchFn;
26188
}

packages/keychain/src/identity.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { makeAuthResponse } from '@stacks/auth';
2+
import { FetchFn, getDefaultFetchFn } from '@stacks/common';
23
import { getPublicKeyFromPrivate, publicKeyToAddress } from '@stacks/encryption';
34
import { bip32 } from 'bitcoinjs-lib';
45
import { Identity as IdentifyInterface, Profile } from './common';
@@ -129,9 +130,9 @@ export class Identity implements IdentifyInterface {
129130
return `${gaiaUrl}${this.address}/profile.json`;
130131
}
131132

132-
async fetchNames() {
133+
async fetchNames(fetchFn: FetchFn = getDefaultFetchFn()) {
133134
const getNamesUrl = `https://stacks-node-api.stacks.co/v1/addresses/bitcoin/${this.address}`;
134-
const res = await fetch(getNamesUrl);
135+
const res = await fetchFn(getNamesUrl);
135136
const data = await res.json();
136137
const { names }: { names: string[] } = data;
137138
return names;

0 commit comments

Comments
 (0)