Skip to content

Commit 3a7576b

Browse files
committed
chore: move POC session auth middleware into separate file, add example as test
1 parent ee784cc commit 3a7576b

File tree

4 files changed

+163
-78
lines changed

4 files changed

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

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)