Skip to content

Commit

Permalink
Merge pull request #39 from emeraldpay/feature/auth-refresh
Browse files Browse the repository at this point in the history
  • Loading branch information
splix authored Jun 12, 2024
2 parents a35b8b1 + 4e031cc commit 35cc856
Show file tree
Hide file tree
Showing 23 changed files with 2,661 additions and 2,563 deletions.
2 changes: 1 addition & 1 deletion api-definitions
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
"build": "npm run clean && npm run init && npm run generate && npm run build:ts",
"build:ts": "tsc",
"clean": "rimraf lib src/generated",
"generate": "npm run generate:js && npm run generate:ts && npm run generate:copy",
"generate": "npm run init && npm run generate:js && npm run generate:ts && npm run generate:copy",
"generate:copy": "rimraf src/generated && cp -r lib/generated src/",
"generate:js": "grpc_tools_node_protoc --js_out=import_style=commonjs:./lib/generated -I ../../api-definitions/proto ../../api-definitions/proto/*.proto",
"generate:js": "grpc_tools_node_protoc --js_out=import_style=commonjs:./lib/generated --plugin=protoc-gen-grpc=`which grpc_tools_node_protoc_plugin` -I ../../api-definitions/proto ../../api-definitions/proto/*.proto",
"generate:ts": "grpc_tools_node_protoc --plugin=protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts --ts_out=./lib/generated -I ../../api-definitions/proto ../../api-definitions/proto/*.proto",
"init": "mkdir -p lib/generated",
"test": "jest"
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export {
} from './Publisher';
export { AlwaysRepeat, ContinueCheck, OnceSuccess, Retry } from './Retry';
export * as address from './typesAddress';
export {
isSecretToken,
isRefreshToken,
SecretToken,
RefreshToken,
AuthRequest,
AuthResponse, AuthResponseOk, AuthResponseFail, isAuthResponseFail, isAuthResponseOk,
BaseAuthClient,
RefreshRequest,
ConvertAuth
} from './typesAuth';
export {
AddressBalance,
BalanceRequest,
Expand Down Expand Up @@ -67,3 +78,9 @@ export {
} from './typesMarket';
export * as token from './typesToken';
export * as transaction from './typesTransaction';
export {
Headers, AuthDetails, JwtSignature,
AuthenticationListener, AuthenticationStatus,
Signer, StandardSigner, NoSigner, NoAuth,
TokenStatus, EmeraldAuthenticator
} from './signature';
310 changes: 310 additions & 0 deletions packages/core/src/signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
import {
BaseAuthClient,
RefreshToken,
SecretToken,
AuthRequest,
AuthResponse,
RefreshRequest,
isAuthResponseFail, isAuthResponseOk
} from "./typesAuth";
const { version: clientVersion } = require('../package.json');

/**
* A general interface to modify request headers
*/
export interface Headers {
/**
* Add a header to the request
*
* @param key header name (ex `Authorization`)
* @param value header value (ex `Bearer <token>`)
*/
add(key: string, value: string): void;
}

/**
* Current authentication details
*/
export interface AuthDetails {
/**
* Add the authentication details to the request headers
* @param meta
*/
applyAuth(meta: Headers): void;

/**
* Check if it's expired and needs to be refreshed from server before applying
*/
isExpired(): boolean;
}

/**
* No authentication, just pass the requests as is
*/
export class NoAuth implements AuthDetails {
applyAuth(_meta: Headers): void {
// do nothing
}

isExpired(): boolean {
return false;
}
}

/**
* Interface to an authentication provider. This provider get an actual auth details, such as JWT, from the server.
*/
export interface EmeraldAuthenticator {
authenticate(): Promise<AuthDetails>;
refresh(): Promise<AuthDetails>
}

export type AuthenticationListener = (status: AuthenticationStatus, tokenStatus: TokenStatus) => void;
export enum AuthenticationStatus {
AUTHENTICATING,
AUTHENTICATED,
ERROR,
}

/**
* JWT based authentication
*/
export class JwtSignature implements AuthDetails {
token: string;
expire: Date;

constructor(token: string, expire: Date) {
this.token = token;
this.expire = expire;
}

applyAuth(meta: Headers): void {
meta.add('Authorization', `Bearer ${this.token}`);
}

public update(token: string, expire: Date): void {
this.token = token;
this.expire = expire;
}

isExpired(): boolean {
return new Date() >= this.expire;
}

}

export enum TokenStatus {
REQUIRED,
REQUESTED,
SUCCESS,
ERROR,
}

/**
* Interface to access the current auth provide per API client
*/
export interface Signer {
/**
* Get current authentication details
*/
getAuth(): Promise<AuthDetails>;

/**
* Listen for authentication status changes
*
* @param listener
*/
setListener(listener: AuthenticationListener): void;

/**
* Set a new authentication provider.
* Usually, it's created automatically by the signer, as it knows what kind of provider it needs.
*
* @param provider
*/
setAuthentication(provider: EmeraldAuthenticator): void;
}

export class NoSigner implements Signer {
getAuth(): Promise<AuthDetails> {
return Promise.reject(new Error('No signer'));
}
setListener(listener: AuthenticationListener): void {
listener(AuthenticationStatus.AUTHENTICATED, TokenStatus.SUCCESS);
}
setAuthentication(_authentication: EmeraldAuthenticator): void {
// do nothing
}
}

/**
* Standard signer based on JWT authentication provider (initiated automatically)
*
* @see JwtAuthProvider
*/
export class StandardSigner implements Signer {
private readonly client: BaseAuthClient;
private readonly secretToken: SecretToken;
private readonly agents: string[];

private tokenStatus = TokenStatus.REQUIRED;
private token: AuthDetails | undefined;
private provider: EmeraldAuthenticator | undefined;
private listener: AuthenticationListener | undefined;
private authenticationStatus = AuthenticationStatus.AUTHENTICATING;

constructor(client: BaseAuthClient, secretToken: SecretToken, agents: string[]) {
this.client = client;
this.secretToken = secretToken;
this.agents = agents;
}

getAuth(): Promise<AuthDetails> {
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();
});
}

// Created a default instance
if (this.provider == null) {
this.provider = new JwtAuthProvider(this.client, this.secretToken, this.agents);
}

// No token yet -> start the authentication
if (this.token == null) {
this.tokenStatus = TokenStatus.REQUESTED;
this.notify(AuthenticationStatus.AUTHENTICATING);

return this.provider
.authenticate()
.then((token) => {
this.token = token;
this.tokenStatus = TokenStatus.SUCCESS;
this.notify(AuthenticationStatus.AUTHENTICATED);

return token;
})
.catch((error) => {
console.warn("Failed to authenticate", error);
this.tokenStatus = TokenStatus.ERROR;
this.notify(AuthenticationStatus.ERROR);

throw error;
});

// current token is expired -> refresh it
} else if (this.token.isExpired()) {
this.tokenStatus = TokenStatus.REQUESTED;
this.notify(AuthenticationStatus.AUTHENTICATING);

return this.provider
.refresh()
.then((token) => {
this.token = token;
this.tokenStatus = TokenStatus.SUCCESS;
this.notify(AuthenticationStatus.AUTHENTICATED);

return token;
})
.catch((error) => {
console.warn("Failed to refresh", error);
this.tokenStatus = TokenStatus.ERROR;
this.notify(AuthenticationStatus.ERROR);

throw error;
});
}

// use the current token
return Promise.resolve(this.token);
}

protected notify(status: AuthenticationStatus): void {
if (status != this.authenticationStatus) {
this.authenticationStatus = status;

this.listener?.(status, this.tokenStatus);
}
}

setListener(listener: AuthenticationListener): void {
this.listener = listener;

listener(this.authenticationStatus, this.tokenStatus);
}

setAuthentication(authentication: EmeraldAuthenticator): void {
this.provider = authentication;
}
}

class JwtAuthProvider implements EmeraldAuthenticator {
private readonly client: BaseAuthClient;
private readonly agents: string[];
private readonly secretToken: SecretToken;
private refreshToken: RefreshToken | undefined;

constructor(client: BaseAuthClient, secretToken: SecretToken, agents: string[]) {
this.client = client;
this.secretToken = secretToken;
if (this.agents == null || this.agents.length == 0) {
this.agents = [`emerald-client/${clientVersion}`];
} else {
this.agents = agents;
}
}

authenticate(): Promise<AuthDetails> {
const authRequest: AuthRequest = {
secret: this.secretToken,
agent: this.agents,
}

return this.client.authenticate(authRequest).then((result: AuthResponse) => {
if (isAuthResponseFail(result)) {
throw new Error(`Failed to auth. Code=${result.status}, message=${result.denyMessage}`);
}
if (isAuthResponseOk(result)) {
this.refreshToken = result.refreshToken;
return new JwtSignature(result.jwt, result.expiresAt);
}
throw new Error(`Unsupported auth`);
});
}

refresh(): Promise<AuthDetails> {
if (this.refreshToken == null) {
return Promise.reject(new Error('No refresh token'));
}

const refreshRequest: RefreshRequest = {
secret: this.secretToken,
refreshToken: this.refreshToken,
}

return this.client.refresh(refreshRequest).then((result: AuthResponse) => {
this.refreshToken = null;
if (isAuthResponseFail(result)) {
throw new Error(`Failed to auth. Code=${result.status}, message=${result.denyMessage}`);
}
if (isAuthResponseOk(result)) {
this.refreshToken = result.refreshToken;
return new JwtSignature(result.jwt, result.expiresAt);
}
throw new Error(`Unsupported auth`);
});
}

}
Loading

0 comments on commit 35cc856

Please sign in to comment.