From 2b28649bbd6b4cd7a2cff096f2829f5f0ecc47b6 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 22 May 2024 22:53:51 +0200 Subject: [PATCH] add support to auth caching --- examples/create-relayer/index.js | 7 +++- packages/base/src/api/api.test.ts | 2 +- packages/base/src/api/api.ts | 34 +++++++++++---- packages/base/src/api/auth-v2.ts | 43 +++++++++++++++++++ packages/base/src/api/client.ts | 70 ++++++++++++++++++++++++++----- packages/base/src/index.ts | 3 +- packages/relay/src/api/index.ts | 27 ++++++++++-- packages/relay/src/relayer.ts | 4 +- 8 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 packages/base/src/api/auth-v2.ts diff --git a/examples/create-relayer/index.js b/examples/create-relayer/index.js index 51190c20..3e6b939e 100644 --- a/examples/create-relayer/index.js +++ b/examples/create-relayer/index.js @@ -3,8 +3,11 @@ require('dotenv').config(); const { RelayClient } = require('@openzeppelin/defender-relay-client'); async function main() { - const creds = { apiKey: process.env.ADMIN_API_KEY, apiSecret: process.env.ADMIN_API_SECRET }; - const client = new RelayClient(creds); + const client = new RelayClient({ + apiKey: process.env.ADMIN_API_KEY, + apiSecret: process.env.ADMIN_API_SECRET, + useCredentialsCaching: false, + }); const createParams = { name: 'MyNewRelayer', diff --git a/packages/base/src/api/api.test.ts b/packages/base/src/api/api.test.ts index 0bb37f2d..b3e83de1 100644 --- a/packages/base/src/api/api.test.ts +++ b/packages/base/src/api/api.test.ts @@ -9,7 +9,7 @@ const token = 'token'; describe('createApi', () => { test('passes correct arguments to axios', () => { - createApi(key, token, apiUrl); + createApi(apiUrl, key, token); expect(axios.create).toBeCalledWith({ baseURL: apiUrl, headers: { diff --git a/packages/base/src/api/api.ts b/packages/base/src/api/api.ts index fd139212..dda18c15 100644 --- a/packages/base/src/api/api.ts +++ b/packages/base/src/api/api.ts @@ -8,13 +8,27 @@ export function rejectWithDefenderApiError(axiosError: AxiosError): Promise, +): AxiosInstance { + const authHeaders = + key && token + ? { + 'X-Api-Key': key, + Authorization: `Bearer ${token}`, + } + : {}; + const instance = axios.create({ baseURL: apiUrl, headers: { - 'X-Api-Key': key, - 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', + ...authHeaders, + ...headers, }, httpsAgent, }); @@ -25,11 +39,17 @@ export function createApi(key: string, token: string, apiUrl: string, httpsAgent export function createAuthenticatedApi( username: string, - session: CognitoUserSession, + accessToken: string, apiUrl: string, httpsAgent?: https.Agent, ): AxiosInstance { - const accessToken = session.getAccessToken().getJwtToken(); - - return createApi(username, accessToken, apiUrl, httpsAgent); + return createApi(apiUrl, username, accessToken, httpsAgent); } + +export function createUnauthorizedApi( + apiUrl: string, + httpsAgent?: https.Agent, + headers?: Record, +): AxiosInstance { + return createApi(apiUrl, undefined, undefined, httpsAgent, headers); +} \ No newline at end of file diff --git a/packages/base/src/api/auth-v2.ts b/packages/base/src/api/auth-v2.ts new file mode 100644 index 00000000..647bb52f --- /dev/null +++ b/packages/base/src/api/auth-v2.ts @@ -0,0 +1,43 @@ +import retry from 'async-retry'; +import { createUnauthorizedApi } from './api'; +import { DefenderApiResponseError } from './api-error'; + +export type AuthType = 'admin' | 'relay'; + +export type AuthCredentials = { + apiKey: string; + secretKey: string; + type: AuthType; +}; + +export type RefreshCredentials = { + apiKey: string; + secretKey: string; + refreshToken: string; + type: AuthType; +}; + +export type AuthResponse = { + accessToken: string; + refreshToken: string; +}; + +export async function authenticateV2(credentials: AuthCredentials, apiUrl: string): Promise { + const api = createUnauthorizedApi(apiUrl); + try { + return await retry(() => api.post('/auth/login', credentials), { retries: 3 }); + } catch (err) { + const errorMessage = (err as DefenderApiResponseError).response.statusText || err; + throw new Error(`Failed to get a token for the API key ${credentials.apiKey}: ${errorMessage}`); + } +} + +export async function refreshSessionV2(credentials: RefreshCredentials, apiUrl: string): Promise { + const api = createUnauthorizedApi(apiUrl); + try { + return await retry(() => api.post('/auth/refresh-token', credentials), { retries: 3 }); + } catch (err) { + const errorMessage = (err as DefenderApiResponseError).response.statusText || err; + throw new Error(`Failed to refresh token for the API key ${credentials.apiKey}: ${errorMessage}`); + } +} diff --git a/packages/base/src/api/client.ts b/packages/base/src/api/client.ts index d5c1d83f..94f399bc 100644 --- a/packages/base/src/api/client.ts +++ b/packages/base/src/api/client.ts @@ -4,50 +4,98 @@ import https from 'https'; import { createAuthenticatedApi } from './api'; import { authenticate, refreshSession } from './auth'; +import { AuthType, authenticateV2, refreshSessionV2 } from './auth-v2'; export type ApiVersion = 'v1' | 'v2'; +export type AuthConfig = { + useCredentialsCaching: boolean; + type: AuthType; +}; + export abstract class BaseApiClient { private api: AxiosInstance | undefined; private version: ApiVersion | undefined; private apiKey: string; private session: CognitoUserSession | undefined; + private sessionV2: { accessToken: string; refreshToken: string } | undefined; private apiSecret: string; private httpsAgent?: https.Agent; + private authConfig: AuthConfig; protected abstract getPoolId(): string; protected abstract getPoolClientId(): string; - protected abstract getApiUrl(v: ApiVersion): string; + protected abstract getApiUrl(v: ApiVersion, type?: AuthType): string; - public constructor(params: { apiKey: string; apiSecret: string; httpsAgent?: https.Agent }) { + public constructor(params: { apiKey: string; apiSecret: string; httpsAgent?: https.Agent; authConfig?: AuthConfig }) { if (!params.apiKey) throw new Error(`API key is required`); if (!params.apiSecret) throw new Error(`API secret is required`); this.apiKey = params.apiKey; this.apiSecret = params.apiSecret; this.httpsAgent = params.httpsAgent; + this.authConfig = params.authConfig ?? { useCredentialsCaching: false, type: 'admin' }; + } + + private async getAccessToken(): Promise { + const userPass = { Username: this.apiKey, Password: this.apiSecret }; + const poolData = { UserPoolId: this.getPoolId(), ClientId: this.getPoolClientId() }; + this.session = await authenticate(userPass, poolData); + return this.session.getAccessToken().getJwtToken(); + } + + private async getAccessTokenV2(): Promise { + if (!this.authConfig.type) throw new Error('Auth type is required to authenticate in auth v2'); + const credentials = { + apiKey: this.apiKey, + secretKey: this.apiSecret, + type: this.authConfig.type, + }; + this.sessionV2 = await authenticateV2(credentials, this.getApiUrl('v1', 'admin')); + return this.sessionV2.accessToken; + } + + private async refreshSession(): Promise { + if (!this.session) return this.getAccessToken(); + const userPass = { Username: this.apiKey, Password: this.apiSecret }; + const poolData = { UserPoolId: this.getPoolId(), ClientId: this.getPoolClientId() }; + this.session = await refreshSession(userPass, poolData, this.session); + return this.session.getAccessToken().getJwtToken(); + } + + private async refreshSessionV2(): Promise { + if (!this.authConfig.type) throw new Error('Auth type is required to refresh session in auth v2'); + if (!this.sessionV2) return this.getAccessTokenV2(); + const credentials = { + apiKey: this.apiKey, + secretKey: this.apiSecret, + refreshToken: this.sessionV2.refreshToken, + type: this.authConfig.type, + }; + this.sessionV2 = await refreshSessionV2(credentials, this.getApiUrl('v1' ,'admin')); + return this.sessionV2.accessToken; } protected async init(v: ApiVersion = 'v1'): Promise { if (!this.api || this.version !== v) { - const userPass = { Username: this.apiKey, Password: this.apiSecret }; - const poolData = { UserPoolId: this.getPoolId(), ClientId: this.getPoolClientId() }; - this.session = await authenticate(userPass, poolData); - this.api = createAuthenticatedApi(userPass.Username, this.session, this.getApiUrl(v), this.httpsAgent); + const accessToken = this.authConfig.useCredentialsCaching + ? await this.getAccessTokenV2() + : await this.getAccessToken(); + this.api = createAuthenticatedApi(this.apiKey, accessToken, this.getApiUrl(v, 'admin'), this.httpsAgent); this.version = v; } return this.api; } protected async refresh(v: ApiVersion = 'v1'): Promise { - if (!this.session) { + if (!this.session && !this.sessionV2) { return this.init(v); } try { - const userPass = { Username: this.apiKey, Password: this.apiSecret }; - const poolData = { UserPoolId: this.getPoolId(), ClientId: this.getPoolClientId() }; - this.session = await refreshSession(userPass, poolData, this.session); - this.api = createAuthenticatedApi(userPass.Username, this.session, this.getApiUrl(v), this.httpsAgent); + const accessToken = this.authConfig.useCredentialsCaching + ? await this.refreshSessionV2() + : await this.refreshSession(); + this.api = createAuthenticatedApi(this.apiKey, accessToken, this.getApiUrl(v, 'admin'), this.httpsAgent); return this.api; } catch (e) { diff --git a/packages/base/src/index.ts b/packages/base/src/index.ts index cf0f87a5..95cf7e4a 100644 --- a/packages/base/src/index.ts +++ b/packages/base/src/index.ts @@ -1,6 +1,7 @@ export { createApi, createAuthenticatedApi } from './api/api'; export { authenticate } from './api/auth'; -export { BaseApiClient, ApiVersion } from './api/client'; +export { BaseApiClient, ApiVersion, AuthConfig } from './api/client'; +export { AuthType } from './api/auth-v2'; export * from './utils/network'; // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/packages/relay/src/api/index.ts b/packages/relay/src/api/index.ts index 6d149f56..9447e46f 100644 --- a/packages/relay/src/api/index.ts +++ b/packages/relay/src/api/index.ts @@ -1,4 +1,4 @@ -import { BaseApiClient, ApiVersion } from '@openzeppelin/defender-base-client'; +import { BaseApiClient, ApiVersion, AuthType } from '@openzeppelin/defender-base-client'; import { ApiRelayerParams, IRelayer, @@ -23,7 +23,19 @@ import { export const RelaySignerApiUrl = () => process.env.DEFENDER_RELAY_SIGNER_API_URL || 'https://api.defender.openzeppelin.com/'; +export const getAdminApiUrl = () => process.env.DEFENDER_API_URL || 'https://defender-api.openzeppelin.com/'; + export class RelayClient extends BaseApiClient { + + public constructor(params: ApiRelayerParams) { + super({ + apiKey: params.apiKey, + apiSecret: params.apiSecret, + httpsAgent: params.httpsAgent, + authConfig: { useCredentialsCaching: params.useCredentialsCaching ?? false, type: 'admin' }, + }); + } + protected getPoolId(): string { return process.env.DEFENDER_RELAY_POOL_ID || 'us-west-2_94f3puJWv'; } @@ -32,7 +44,8 @@ export class RelayClient extends BaseApiClient { return process.env.DEFENDER_RELAY_POOL_CLIENT_ID || '40e58hbc7pktmnp9i26hh5nsav'; } - protected getApiUrl(v: ApiVersion = 'v1'): string { + protected getApiUrl(v: ApiVersion = 'v1', type?: AuthType): string { + if (type === 'admin') return getAdminApiUrl(); if (v === 'v2') { return process.env.DEFENDER_API_V2_URL || 'https://defender-api.openzeppelin.com/v2/'; } @@ -109,7 +122,12 @@ export class ApiRelayer extends BaseApiClient implements IRelayer { private jsonRpcRequestNextId: number; public constructor(params: ApiRelayerParams) { - super(params); + super({ + apiKey: params.apiKey, + apiSecret: params.apiSecret, + httpsAgent: params.httpsAgent, + authConfig: { useCredentialsCaching: params.useCredentialsCaching ?? false, type: 'relay' }, + }); this.jsonRpcRequestNextId = 1; } @@ -121,7 +139,8 @@ export class ApiRelayer extends BaseApiClient implements IRelayer { return process.env.DEFENDER_RELAY_SIGNER_POOL_CLIENT_ID || '1bpd19lcr33qvg5cr3oi79rdap'; } - protected getApiUrl(_: ApiVersion): string { + protected getApiUrl(_: ApiVersion, type?: AuthType): string { + if (type === 'admin') return getAdminApiUrl(); return RelaySignerApiUrl(); } diff --git a/packages/relay/src/relayer.ts b/packages/relay/src/relayer.ts index 36bbf300..193b4dfc 100644 --- a/packages/relay/src/relayer.ts +++ b/packages/relay/src/relayer.ts @@ -142,8 +142,8 @@ export type RelayerTransaction = RelayerLegacyTransaction | RelayerEIP1559Transa export type PaginatedTransactionListResponse = RelayerTransaction[] | { items: RelayerTransaction[]; next?: string }; export type RelayerParams = ApiRelayerParams | AutotaskRelayerParams; -export type ApiRelayerParams = { apiKey: string; apiSecret: string; httpsAgent?: https.Agent }; -export type AutotaskRelayerParams = { credentials: string; relayerARN: string; httpsAgent?: https.Agent }; +export type ApiRelayerParams = { apiKey: string; apiSecret: string; httpsAgent?: https.Agent, useCredentialsCaching?: boolean; }; +export type AutotaskRelayerParams = { credentials: string; relayerARN: string; httpsAgent?: https.Agent, useCredentialsCaching?: boolean; }; export type JsonRpcResponse = { id: number | null;