Skip to content

Commit

Permalink
Merge pull request #24 from emeraldpay/single-token-3
Browse files Browse the repository at this point in the history
problem: token requested multiple times
  • Loading branch information
splix authored Jun 8, 2023
2 parents 0fc718e + 4f71932 commit 86acbe5
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 43 deletions.
4 changes: 2 additions & 2 deletions packages/node/src/EmeraldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions packages/node/src/__integration-tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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<AuthMetadata> {
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);
}
});
});
100 changes: 70 additions & 30 deletions packages/node/src/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,41 @@ 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();

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();
Expand All @@ -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<AuthMetadata> {
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);
}
}
}
Expand All @@ -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<AuthMetadata>;
}

Expand All @@ -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()) {
Expand Down
22 changes: 11 additions & 11 deletions packages/node/src/signature.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
2 changes: 2 additions & 0 deletions packages/node/src/wrapped/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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) {
const agent = [...agents, `emerald-client-node/${clientVersion}`].join(' ');

this.client = new ProtoAuthClient(address, credentials, { 'grpc.primary_user_agent': agent });
this.channel = new NativeChannel(this.client);
this.credentials = credentials;
this.retries = retries;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/wrapped/BlockchainClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/wrapped/MarketClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/wrapped/MonitoringClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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) {
const agent = [...agents, `emerald-client-node/${clientVersion}`].join(' ');

this.client = new ProtoMonitoringClient(address, credentials, { 'grpc.primary_user_agent': agent });
this.channel = new NativeChannel(this.client);
this.credentials = credentials;
this.retries = retries;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/wrapped/TransactionClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down

0 comments on commit 86acbe5

Please sign in to comment.