Skip to content

Commit ce5899b

Browse files
committed
no message
1 parent ee784cc commit ce5899b

File tree

4 files changed

+164
-78
lines changed

4 files changed

+164
-78
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// **NOTE** This is an untested proof-of-concept for using fetch middleware to handle
2+
// session API key based authentication.
3+
4+
import { FetchMiddleware, ResponseContext } from './fetchUtil';
5+
6+
export interface SessionAuthDataStore {
7+
get(host: string): Promise<{ authKey: string } | undefined> | { authKey: string } | undefined;
8+
set(host: string, authData: { authKey: string }): Promise<void> | void;
9+
delete(host: string): Promise<void> | void;
10+
}
11+
12+
export interface ApiSessionAuthMiddlewareOpts {
13+
/** The middleware / API key header will only be added to requests matching this host. */
14+
host?: RegExp | string;
15+
/** The http header name used for specifying the API key value. */
16+
httpHeader?: string;
17+
authPath: string;
18+
authRequestMetadata: Record<string, string>;
19+
authDataStore?: SessionAuthDataStore;
20+
}
21+
22+
export function createApiSessionAuthMiddleware({
23+
host = /(.*)api(.*)\.stacks\.co$/i,
24+
httpHeader = 'x-api-key',
25+
authPath = '/request_key',
26+
authRequestMetadata = {},
27+
authDataStore = createInMemoryAuthDataStore(),
28+
}: ApiSessionAuthMiddlewareOpts): FetchMiddleware {
29+
// Local temporary cache of auth request promises, used so that multiple re-auth requests are
30+
// not running in parallel. Key is the request host.
31+
const pendingAuthRequests = new Map<string, Promise<{ authKey: string }>>();
32+
33+
const authMiddleware: FetchMiddleware = {
34+
pre: async context => {
35+
const reqUrl = new URL(context.url);
36+
let hostMatches = false;
37+
if (typeof host === 'string') {
38+
hostMatches = host === reqUrl.host;
39+
} else {
40+
hostMatches = !!host.exec(reqUrl.host);
41+
}
42+
const authData = await authDataStore.get(reqUrl.host);
43+
if (hostMatches && authData) {
44+
try {
45+
context.init.headers = setRequestHeader(context.init, httpHeader, authData.authKey);
46+
} catch (error) {
47+
console.error(`Error awaiting key auth response: ${error}`);
48+
}
49+
}
50+
},
51+
post: async context => {
52+
const reqUrl = new URL(context.url);
53+
let hostMatches = false;
54+
if (typeof host === 'string') {
55+
hostMatches = host === reqUrl.host;
56+
} else {
57+
hostMatches = !!host.exec(reqUrl.host);
58+
}
59+
60+
// If request is for configured host, and response was `401 Unauthorized`,
61+
// then request auth key and retry request.
62+
if (hostMatches && context.response.status === 401) {
63+
// Check if for any currently pending auth requests and re-use it to avoid creating multiple in parallel.
64+
let pendingAuthRequest = pendingAuthRequests.get(reqUrl.host);
65+
if (!pendingAuthRequest) {
66+
pendingAuthRequest = resolveAuthToken(context, authPath, authRequestMetadata)
67+
.then(async result => {
68+
// If the request is successfull, add the key to storage.
69+
await authDataStore.set(reqUrl.host, result);
70+
return result;
71+
})
72+
.finally(() => {
73+
// When the request is completed (either successful or rejected) clear the promise.
74+
pendingAuthRequests.delete(reqUrl.host);
75+
});
76+
}
77+
const { authKey } = await pendingAuthRequest;
78+
// Retry the request using the new API key auth header.
79+
context.init.headers = setRequestHeader(context.init, httpHeader, authKey);
80+
return context.fetch(context.url, context.init);
81+
} else {
82+
return context.response;
83+
}
84+
},
85+
};
86+
return authMiddleware;
87+
}
88+
89+
function createInMemoryAuthDataStore(): SessionAuthDataStore {
90+
const map = new Map<string, { authKey: string }>();
91+
const store: SessionAuthDataStore = {
92+
get: host => {
93+
return map.get(host);
94+
},
95+
set: (host, authData) => {
96+
map.set(host, authData);
97+
},
98+
delete: host => {
99+
map.delete(host);
100+
},
101+
};
102+
return store;
103+
}
104+
105+
function setRequestHeader(requestInit: RequestInit, headerKey: string, headerValue: string) {
106+
const headers = new Headers(requestInit.headers);
107+
headers.set(headerKey, headerValue);
108+
return headers;
109+
}
110+
111+
async function resolveAuthToken(
112+
context: ResponseContext,
113+
authPath: string,
114+
authRequestMetadata: Record<string, string>
115+
) {
116+
const reqUrl = new URL(context.url);
117+
const authEndpoint = new URL(reqUrl.origin);
118+
authEndpoint.pathname = authPath;
119+
const authReq = await context.fetch(authEndpoint.toString(), {
120+
method: 'POST',
121+
headers: {
122+
'Content-Type': 'application/json',
123+
Accept: 'application/json',
124+
},
125+
body: JSON.stringify(authRequestMetadata),
126+
});
127+
if (authReq.ok) {
128+
const authRespBody: { auth_key: string } = await authReq.json();
129+
return { authKey: authRespBody.auth_key };
130+
} else {
131+
let respBody = '';
132+
try {
133+
respBody = await authReq.text();
134+
} catch (error) {
135+
respBody = `Error fetching API auth key: ${authReq.status} - Error fetching response body: ${error}`;
136+
}
137+
throw new Error(`Error fetching API auth key: ${authReq.status} - ${respBody}`);
138+
}
139+
}

packages/common/src/fetchUtil.ts

Lines changed: 1 addition & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -24,80 +24,6 @@ export interface FetchMiddleware {
2424
pre?: (context: RequestContext) => PromiseLike<FetchParams | void> | FetchParams | void;
2525
post?: (context: ResponseContext) => Promise<Response | void> | Response | void;
2626
}
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-
10127
export interface ApiKeyMiddlewareOpts {
10228
/** The middleware / API key header will only be added to requests matching this host. */
10329
host?: RegExp | string;
@@ -156,7 +82,7 @@ export function getDefaultFetchFn(...args: any[]): FetchFn {
15682
}
15783
const middlewares = [...getDefaultMiddleware(), ...middlewareOpt];
15884
const fetchFn = async (url: string, init?: RequestInit | undefined): Promise<Response> => {
159-
let fetchParams = { url, init: init || {} };
85+
let fetchParams = { url, init: init ?? {} };
16086
for (const middleware of middlewares) {
16187
if (middleware.pre) {
16288
const result = await Promise.resolve(

packages/network/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export class StacksMainnet extends StacksNetwork {
126126
version = TransactionVersion.Mainnet;
127127
chainId = ChainID.Mainnet;
128128

129-
constructor(opts?: NetworkConfig) {
129+
constructor(opts?: Partial<NetworkConfig>) {
130130
super({
131131
url: opts?.url ?? HIRO_MAINNET_DEFAULT,
132132
fetchFn: opts?.fetchFn,
@@ -138,7 +138,7 @@ export class StacksTestnet extends StacksNetwork {
138138
version = TransactionVersion.Testnet;
139139
chainId = ChainID.Testnet;
140140

141-
constructor(opts?: NetworkConfig) {
141+
constructor(opts?: Partial<NetworkConfig>) {
142142
super({
143143
url: opts?.url ?? HIRO_TESTNET_DEFAULT,
144144
fetchFn: opts?.fetchFn,
@@ -150,7 +150,7 @@ export class StacksMocknet extends StacksNetwork {
150150
version = TransactionVersion.Testnet;
151151
chainId = ChainID.Testnet;
152152

153-
constructor(opts?: NetworkConfig) {
153+
constructor(opts?: Partial<NetworkConfig>) {
154154
super({
155155
url: opts?.url ?? HIRO_MOCKNET_DEFAULT,
156156
fetchFn: opts?.fetchFn,

packages/transactions/tests/builder.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createApiKeyMiddleware, getDefaultFetchFn } from '@stacks/common';
12
import { StacksMainnet, StacksTestnet } from '@stacks/network';
23
import * as fs from 'fs';
34
import fetchMock from 'jest-fetch-mock';
@@ -57,6 +58,26 @@ beforeEach(() => {
5758
jest.resetModules();
5859
});
5960

61+
test('API key middleware - get nonce', async () => {
62+
const senderAddress = 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6';
63+
64+
const apiKey = '1234-my-api-key-example';
65+
const fetchFn = getDefaultFetchFn(createApiKeyMiddleware({ apiKey }));
66+
const network = new StacksMainnet({ fetchFn });
67+
68+
fetchMock.mockOnce(`{"balance": "0", "nonce": "123"}`);
69+
70+
const fetchNonce = await getNonce(senderAddress, network);
71+
expect(fetchNonce).toBe(123n);
72+
expect(fetchMock.mock.calls.length).toEqual(1);
73+
expect(fetchMock.mock.calls[0][0]).toEqual(
74+
'https://stacks-node-api.mainnet.stacks.co/v2/accounts/STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6?proof=0'
75+
);
76+
const callHeaders = new Headers(fetchMock.mock.calls[0][1]?.headers);
77+
expect(callHeaders.has('x-api-key')).toBeTruthy();
78+
expect(callHeaders.get('x-api-key')).toBe(apiKey);
79+
});
80+
6081
test('Make STX token transfer with set tx fee', async () => {
6182
const recipient = standardPrincipalCV('SP3FGQ8Z7JY9BWYZ5WM53E0M9NK7WHJF0691NZ159');
6283
const amount = 12345;

0 commit comments

Comments
 (0)