diff --git a/packages/node/src/EmeraldApi.ts b/packages/node/src/EmeraldApi.ts index 834c927..47f8657 100644 --- a/packages/node/src/EmeraldApi.ts +++ b/packages/node/src/EmeraldApi.ts @@ -16,8 +16,8 @@ export class EmeraldApi { this.hostname = hostname; } - static devApi(): EmeraldApi { - return new EmeraldApi('api.emeraldpay.dev:443'); + static devApi(credentials?: ChannelCredentials): EmeraldApi { + return new EmeraldApi('api.emeraldpay.dev:443', credentials); } static localApi(port = 50051, credentials?: ChannelCredentials): EmeraldApi { diff --git a/packages/node/src/__integration-tests__/auth.test.ts b/packages/node/src/__integration-tests__/auth.test.ts index 865984b..fbe7821 100644 --- a/packages/node/src/__integration-tests__/auth.test.ts +++ b/packages/node/src/__integration-tests__/auth.test.ts @@ -1,5 +1,7 @@ import { Blockchain } from '@emeraldpay/api'; +import { EmeraldAuthentication, TokenStatus, emeraldCredentials } from '../credentials'; import { EmeraldApi } from '../EmeraldApi'; +import { AuthMetadata } from '../signature'; jest.setTimeout(30000); @@ -29,4 +31,93 @@ describe('Auth', () => { expect(exception).toBeDefined(); } }); + + test('auth token received once and shared between all clients', async () => { + const api = EmeraldApi.devApi(); + + const blockchainClient = api.blockchain(); + const marketClient = api.market(); + const monitoringClient = api.monitoring(); + const transactionClient = api.transaction(); + + const results = await Promise.all([ + blockchainClient.estimateFees({ + blockchain: Blockchain.TESTNET_GOERLI, + blocks: 1, + mode: 'avgLast', + }), + marketClient.getRates([{ base: 'ETH', target: 'USD' }]), + monitoringClient.ping(), + transactionClient.getXpubState({ + address: + 'vpub5bGr72An7v5pmqBZecLVnd74Kpip5t9GSPX7ULe9LazdvWq1ECkJ' + + 'Tpsf6YGFcD4T1McCvcaVdmuHZoo1qaNsddqREiheeFfzUuJ1vMjLFWE', + blockchain: Blockchain.TESTNET_BITCOIN, + }), + ]); + + expect(results.length).toEqual(4); + + const options = { service_url: '' }; + + const blockchainMetadata = await blockchainClient.credentials._getCallCredentials().generateMetadata(options); + const marketMetadata = await marketClient.credentials._getCallCredentials().generateMetadata(options); + const monitoringMetadata = await monitoringClient.credentials._getCallCredentials().generateMetadata(options); + const transactionMetadata = await transactionClient.credentials._getCallCredentials().generateMetadata(options); + + const [blockchainAuthorization] = blockchainMetadata.get('authorization'); + const [marketAuthorization] = marketMetadata.get('authorization'); + const [monitoringAuthorization] = monitoringMetadata.get('authorization'); + const [transactionAuthorization] = transactionMetadata.get('authorization'); + + expect(blockchainAuthorization).toEqual(marketAuthorization); + expect(marketAuthorization).toEqual(monitoringAuthorization); + expect(monitoringAuthorization).toEqual(transactionAuthorization); + }); + + test('token awaiting stopped in other clients when first request failed', async () => { + const credentials = emeraldCredentials('localhost:50051', ['fake-client/0.0.0'], 'fake-client'); + + class FakeAuthentication implements EmeraldAuthentication { + authenticate(): Promise { + return new Promise((resolve, reject) => setTimeout(reject, 250)); + } + } + + let tokenStatus: TokenStatus | null = null; + + credentials.setAuthentication(new FakeAuthentication()); + credentials.setListener((...statuses) => ([, tokenStatus] = statuses)); + + const api = EmeraldApi.devApi(credentials.getChannelCredentials()); + + const blockchainClient = api.blockchain(); + const marketClient = api.market(); + const monitoringClient = api.monitoring(); + const transactionClient = api.transaction(); + + try { + const results = await Promise.all([ + blockchainClient.estimateFees({ + blockchain: Blockchain.TESTNET_GOERLI, + blocks: 1, + mode: 'avgLast', + }), + marketClient.getRates([{ base: 'ETH', target: 'USD' }]), + monitoringClient.ping(), + transactionClient.getXpubState({ + address: + 'vpub5bGr72An7v5pmqBZecLVnd74Kpip5t9GSPX7ULe9LazdvWq1ECkJ' + + 'Tpsf6YGFcD4T1McCvcaVdmuHZoo1qaNsddqREiheeFfzUuJ1vMjLFWE', + blockchain: Blockchain.TESTNET_BITCOIN, + }), + ]); + + expect(results.length).toEqual(0); + } catch (exception) { + expect(exception).toBeDefined(); + + expect(tokenStatus).toEqual(TokenStatus.ERROR); + } + }); }); diff --git a/packages/node/src/credentials.ts b/packages/node/src/credentials.ts index 41ba34e..afaad0e 100644 --- a/packages/node/src/credentials.ts +++ b/packages/node/src/credentials.ts @@ -11,24 +11,33 @@ export enum AuthenticationStatus { ERROR, } -export type AuthenticationListener = (status: AuthenticationStatus) => void; +export enum TokenStatus { + REQUIRED, + REQUESTED, + SUCCESS, + ERROR, +} -export class CredentialsContext { - public url: string; +export type AuthenticationListener = (status: AuthenticationStatus, tokenStatus: TokenStatus) => void; +export class CredentialsContext { private readonly agents: string[]; private readonly channelCredentials: ChannelCredentials; private readonly ssl: ChannelCredentials; private readonly userId: string; - private authentication: EmeraldAuthentication; - private listener?: AuthenticationListener; - private status = AuthenticationStatus.AUTHENTICATING; - private token?: AuthMetadata; + private authenticationStatus = AuthenticationStatus.AUTHENTICATING; + private tokenStatus = TokenStatus.REQUIRED; - constructor(url: string, agents: string[], userId: string) { + private authentication: EmeraldAuthentication | undefined; + private listener: AuthenticationListener | undefined; + private token: AuthMetadata | undefined; + + readonly address: string; + + constructor(address: string, agents: string[], userId: string) { + this.address = address; this.agents = agents; - this.url = url; this.userId = userId; this.ssl = credentials.createSsl(); @@ -36,7 +45,7 @@ export class CredentialsContext { const ssl = this.getSsl(); const callCredentials = credentials.createFromMetadataGenerator( - (params: { service_url: string }, callback: (error: Error | null, metadata?: Metadata) => void) => { + (params: { service_url: string }, callback: (error: Error | null, metadata?: Metadata) => void) => this.getSigner() .then((auth) => { const meta = new Metadata(); @@ -59,47 +68,78 @@ export class CredentialsContext { this.notify(AuthenticationStatus.ERROR); callback(new Error('Unable to get token')); - }); - }, + }), ); this.channelCredentials = credentials.combineChannelCredentials(ssl, callCredentials); } - public getChannelCredentials(): ChannelCredentials { + getChannelCredentials(): ChannelCredentials { return this.channelCredentials; } - public setListener(listener: AuthenticationListener): void { - this.listener = listener; - - listener(this.status); + setAuthentication(authentication: EmeraldAuthentication): void { + this.authentication = authentication; } - protected getSsl(): ChannelCredentials { - return this.ssl; + setListener(listener: AuthenticationListener): void { + this.listener = listener; + + listener(this.authenticationStatus, this.tokenStatus); } protected getSigner(): Promise { - if (!this.authentication) { - this.authentication = new JwtUserAuth(this.url, this.getSsl(), this.agents); + if (this.tokenStatus === TokenStatus.REQUESTED) { + return new Promise((resolve, reject) => { + const awaitToken = (): void => { + switch (this.tokenStatus) { + case TokenStatus.ERROR: + return reject(); + case TokenStatus.SUCCESS: + return resolve(this.token); + default: + setTimeout(awaitToken, 50); + } + }; + + awaitToken(); + }); + } + + if (this.authentication == null) { + this.authentication = new JwtUserAuth(this.address, this.getSsl(), this.agents); } if (this.token == null) { - return this.authentication.authenticate(this.agents, this.userId).then((token) => { - this.token = token; + this.tokenStatus = TokenStatus.REQUESTED; - return token; - }); + return this.authentication + .authenticate(this.agents, this.userId) + .then((token) => { + this.token = token; + this.tokenStatus = TokenStatus.SUCCESS; + + return token; + }) + .catch((error) => { + this.tokenStatus = TokenStatus.ERROR; + + throw error; + }); } return Promise.resolve(this.token); } + protected getSsl(): ChannelCredentials { + return this.ssl; + } + protected notify(status: AuthenticationStatus): void { - if (this.listener && status != this.status) { - this.status = status; - this.listener(status); + if (status != this.authenticationStatus) { + this.authenticationStatus = status; + + this.listener?.(status, this.tokenStatus); } } } @@ -108,7 +148,7 @@ export function emeraldCredentials(url: string, agents: string[], userId: string return new CredentialsContext(url, agents, userId); } -interface EmeraldAuthentication { +export interface EmeraldAuthentication { authenticate(agents: string[], userId: string): Promise; } @@ -125,10 +165,10 @@ class JwtUserAuth implements EmeraldAuthentication { tempAuth.setId(userId); - authRequest.setTempAuth(tempAuth); authRequest.setAgentDetailsList([...agents, `emerald-client-node/${clientVersion}`]); authRequest.setCapabilitiesList(['JWT_RS256']); authRequest.setScopesList(['BASIC_USER']); + authRequest.setTempAuth(tempAuth); return this.client.authenticate(authRequest).then((result: AuthResponse) => { if (!result.getSucceed()) { diff --git a/packages/node/src/signature.ts b/packages/node/src/signature.ts index 811aab8..caac1d1 100644 --- a/packages/node/src/signature.ts +++ b/packages/node/src/signature.ts @@ -1,19 +1,19 @@ -import {Metadata} from "@grpc/grpc-js"; +import { Metadata } from '@grpc/grpc-js'; export interface AuthMetadata { - add(meta: Metadata) + add(meta: Metadata); } export class JwtSignature implements AuthMetadata { - readonly token: string; - readonly expire: Date; + readonly token: string; + readonly expire: Date; - constructor(token: string, expire: Date) { - this.token = token; - this.expire = expire; - } + constructor(token: string, expire: Date) { + this.token = token; + this.expire = expire; + } - add(meta: Metadata) { - meta.add("Authorization", "Bearer " + this.token); - } + add(meta: Metadata): void { + meta.add('Authorization', `Bearer ${this.token}`); + } } diff --git a/packages/node/src/wrapped/Auth.ts b/packages/node/src/wrapped/Auth.ts index 0228ba2..498f2f6 100644 --- a/packages/node/src/wrapped/Auth.ts +++ b/packages/node/src/wrapped/Auth.ts @@ -9,6 +9,7 @@ const { version: clientVersion } = require('../../package.json'); export class AuthClient { readonly client: ProtoAuthClient; readonly channel: NativeChannel; + readonly credentials: ChannelCredentials; readonly retries: number; constructor(address: string, credentials: ChannelCredentials, agents: string[], retries = 3) { @@ -16,6 +17,7 @@ export class AuthClient { this.client = new ProtoAuthClient(address, credentials, { 'grpc.primary_user_agent': agent }); this.channel = new NativeChannel(this.client); + this.credentials = credentials; this.retries = retries; } diff --git a/packages/node/src/wrapped/BlockchainClient.ts b/packages/node/src/wrapped/BlockchainClient.ts index c676543..6686e14 100644 --- a/packages/node/src/wrapped/BlockchainClient.ts +++ b/packages/node/src/wrapped/BlockchainClient.ts @@ -34,6 +34,7 @@ const { version: clientVersion } = require('../../package.json'); export class BlockchainClient { readonly client: ProtoBlockchainClient; readonly channel: NativeChannel; + readonly credentials: ChannelCredentials; readonly retries: number; private readonly convert = new ConvertBlockchain(classFactory); @@ -43,6 +44,7 @@ export class BlockchainClient { this.client = new ProtoBlockchainClient(address, credentials, { 'grpc.primary_user_agent': agent }); this.channel = new NativeChannel(this.client); + this.credentials = credentials; this.retries = retries; } diff --git a/packages/node/src/wrapped/MarketClient.ts b/packages/node/src/wrapped/MarketClient.ts index 344a190..4aff5e2 100644 --- a/packages/node/src/wrapped/MarketClient.ts +++ b/packages/node/src/wrapped/MarketClient.ts @@ -16,6 +16,7 @@ const { version: clientVersion } = require('../../package.json'); export class MarketClient { readonly client: ProtoMarketClient; readonly channel: NativeChannel; + readonly credentials: ChannelCredentials; readonly retries: number; private readonly convert = new ConvertMarket(classFactory); @@ -25,6 +26,7 @@ export class MarketClient { this.client = new ProtoMarketClient(address, credentials, { 'grpc.primary_user_agent': agent }); this.channel = new NativeChannel(this.client); + this.credentials = credentials; this.retries = retries; } diff --git a/packages/node/src/wrapped/MonitoringClient.ts b/packages/node/src/wrapped/MonitoringClient.ts index 82d08f7..5dec33a 100644 --- a/packages/node/src/wrapped/MonitoringClient.ts +++ b/packages/node/src/wrapped/MonitoringClient.ts @@ -9,6 +9,7 @@ const { version: clientVersion } = require('../../package.json'); export class MonitoringClient { readonly client: ProtoMonitoringClient; readonly channel: NativeChannel; + readonly credentials: ChannelCredentials; readonly retries: number; constructor(address: string, credentials: ChannelCredentials, agents: string[], retries = 3) { @@ -16,6 +17,7 @@ export class MonitoringClient { this.client = new ProtoMonitoringClient(address, credentials, { 'grpc.primary_user_agent': agent }); this.channel = new NativeChannel(this.client); + this.credentials = credentials; this.retries = retries; } diff --git a/packages/node/src/wrapped/TransactionClient.ts b/packages/node/src/wrapped/TransactionClient.ts index eaa5353..4faf631 100644 --- a/packages/node/src/wrapped/TransactionClient.ts +++ b/packages/node/src/wrapped/TransactionClient.ts @@ -16,6 +16,7 @@ const { version: clientVersion } = require('../../package.json'); export class TransactionClient { readonly client: ProtoTransactionClient; readonly channel: NativeChannel; + readonly credentials: ChannelCredentials; readonly retries: number; private readonly convert: transaction.Convert = new transaction.Convert(classFactory); @@ -25,6 +26,7 @@ export class TransactionClient { this.client = new ProtoTransactionClient(address, credentials, { 'grpc.primary_user_agent': agent }); this.channel = new NativeChannel(this.client); + this.credentials = credentials; this.retries = retries; }