From 628c47635cf375e9be6c9368ceb9adeff2d518e3 Mon Sep 17 00:00:00 2001 From: Igor Artamonov Date: Wed, 11 Sep 2024 20:37:37 +0100 Subject: [PATCH 1/2] problem: doesn't accept a one-time token to authenticate --- packages/core/src/typesAuth.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/typesAuth.ts b/packages/core/src/typesAuth.ts index c7afc0e..5ba75d4 100644 --- a/packages/core/src/typesAuth.ts +++ b/packages/core/src/typesAuth.ts @@ -8,8 +8,13 @@ export type TokenId = UUID; export type OrganizationId = UUID; export type ProjectId = UUID; +/** + * A token to authenticate with API. Format: emrld_ + 38 random alphanumeric characters. + * When it's a one-time auth token, it starts with "emrld_temp_" + */ export type SecretToken = string; -const SecretTokenRegex = new RegExp('^emrld_[0-9a-zA-Z]{38}$'); + +const SecretTokenRegex = new RegExp('^emrld_(temp_)?[0-9a-zA-Z]{38}$'); export function isSecretToken(token: string): token is SecretToken { return SecretTokenRegex.test(token); From 1877daedcf43fffc233654c268dae8c0515845e1 Mon Sep 17 00:00:00 2001 From: Igor Artamonov Date: Wed, 11 Sep 2024 21:59:15 +0100 Subject: [PATCH 2/2] solution: api to issue a token --- api-definitions | 2 +- packages/core/src/index.ts | 1 + packages/core/src/typesAuth.ts | 63 +++++++++++++++++++ packages/node/src/EmeraldApi.ts | 4 +- .../src/__integration-tests__/auth.test.ts | 22 ++++++- packages/node/src/wrapped/Auth.ts | 9 ++- packages/node/src/wrapped/Factory.ts | 2 + packages/web/src/wrapped/AuthClient.ts | 12 +++- packages/web/src/wrapped/Factory.ts | 3 + 9 files changed, 110 insertions(+), 8 deletions(-) diff --git a/api-definitions b/api-definitions index eae9026..7e76b99 160000 --- a/api-definitions +++ b/api-definitions @@ -1 +1 @@ -Subproject commit eae90263ae3b22f8c73b7051cf3bf672a4116efe +Subproject commit 7e76b9943c2a8a6a69c906b7a5f248646b24a17b diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40387ca..69b6f92 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export { ConvertAuth, ListTokensRequest, ListTokensResponse, TokenDetails, WhoIAmResponse, IAmAuthenticated, IAMUnauthenticated, + IssueTokenRequest, IssuedTokenResponse, } from './typesAuth'; export { AddressBalance, diff --git a/packages/core/src/typesAuth.ts b/packages/core/src/typesAuth.ts index 5ba75d4..44f274f 100644 --- a/packages/core/src/typesAuth.ts +++ b/packages/core/src/typesAuth.ts @@ -83,6 +83,43 @@ export type TokenDetails = { createdAt: Date, } +export type IssueTokenRequest = { + /** + * Type of the token to issue + * - "temp" - one-time token + */ + type: "temp", + + /** + * The scopes to be used for the token. Cannot be larger that the current authenticated scopes. + */ + scopes?: string[], + + /** + * The user id associated with the token, i.e. who will use the token. + * There are restrictions who can set this. In short the token issuer must be in control of the user / impersonate the user. + */ + userId?: string, + + /** + * A timestamp when it expires. + * For a temp one-time token, by default, it's 1 Day and cannot be more than 30 days. + */ + expireAt?: Date, +} + +export type IssuedTokenResponse = { + /** + * The issued token + */ + secret: SecretToken, + + /** + * When the token expires + */ + expiresAt: Date, +} + export class ConvertAuth { private readonly factory: MessageFactory; @@ -162,6 +199,32 @@ export class ConvertAuth { } } + public issueTokenRequest(req: IssueTokenRequest): auth_pb.IssueTokenRequest { + const result: auth_pb.IssueTokenRequest = this.factory('auth_pb.IssueTokenRequest'); + if (req.type == "temp") { + result.setType(auth_pb.IssueTokenRequest.TokenType.TEMP); + } + if (req.scopes) { + result.setScopesList(req.scopes); + } + if (req.userId) { + result.setUserId(req.userId); + } + if (req.expireAt) { + result.setExpireAt(req.expireAt.getTime()); + } + return result; + } + + public issuedTokenResponse(res: auth_pb.IssuedTokenResponse): IssuedTokenResponse { + return { + secret: res.getAuthSecret(), + + // when backed returns 0, it means "never expires" (though it almost always has some actual date). + // Let's set 10 years from now here, to avoid unnecessary null checks + expiresAt: res.getExpiresAt() > 0 ? new Date(res.getExpiresAt()) : new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000), + } + } } /** diff --git a/packages/node/src/EmeraldApi.ts b/packages/node/src/EmeraldApi.ts index e9c71f2..1e8d57a 100644 --- a/packages/node/src/EmeraldApi.ts +++ b/packages/node/src/EmeraldApi.ts @@ -20,9 +20,9 @@ export class EmeraldApi { this.hostname = hostname; } - static devApi(credentials?: ChannelCredentials): EmeraldApi { + static devApi(token?: SecretToken | undefined, credentials?: ChannelCredentials): EmeraldApi { // a dev token with access only from the internal network - let devToken = 'emrld_8ntrHbZN67DF8TWKgCMO1I9nSaMG0cpoMhj3GP'; + let devToken = token ?? 'emrld_8ntrHbZN67DF8TWKgCMO1I9nSaMG0cpoMhj3GP'; return new EmeraldApi('api.emeraldpay.dev:443', devToken, credentials); } diff --git a/packages/node/src/__integration-tests__/auth.test.ts b/packages/node/src/__integration-tests__/auth.test.ts index 8b4a987..384a887 100644 --- a/packages/node/src/__integration-tests__/auth.test.ts +++ b/packages/node/src/__integration-tests__/auth.test.ts @@ -1,4 +1,4 @@ -import {Blockchain, AuthDetails, JwtSignature, EmeraldAuthenticator, TokenStatus} from '@emeraldpay/api'; +import {Blockchain, AuthDetails, JwtSignature, EmeraldAuthenticator, TokenStatus, isSecretToken} from '@emeraldpay/api'; import { emeraldCredentials } from '../credentials'; import { EmeraldApi } from '../EmeraldApi'; @@ -16,6 +16,24 @@ describe('Auth', () => { expect(balance).toBeDefined(); }); + test('auth with temp token', async () => { + const client = EmeraldApi.devApi().auth(); + + const token = await client.issueToken({ + type: 'temp', + }); + + expect(token).toBeDefined(); + expect(token.secret).toBeDefined(); + expect(token.expiresAt).toBeDefined(); + expect(isSecretToken(token.secret)).toBeTruthy(); + + const client2 = EmeraldApi.devApi(token.secret).auth(); + + const me = await client2.whoIAm(); + expect(me.authenticated).toBeTruthy(); + }); + test('is authenticated', async () => { const client = EmeraldApi.devApi().auth(); @@ -99,7 +117,7 @@ describe('Auth', () => { credentials.setAuthentication(new FakeAuthentication()); credentials.setListener((...statuses) => ([, tokenStatus] = statuses)); - const api = EmeraldApi.devApi(credentials.getChannelCredentials()); + const api = EmeraldApi.devApi(null, credentials.getChannelCredentials()); const blockchainClient = api.blockchain(); const marketClient = api.market(); diff --git a/packages/node/src/wrapped/Auth.ts b/packages/node/src/wrapped/Auth.ts index ffa55a1..8ccb75f 100644 --- a/packages/node/src/wrapped/Auth.ts +++ b/packages/node/src/wrapped/Auth.ts @@ -1,5 +1,5 @@ import { - ConnectionListener, ConvertAuth, + ConnectionListener, ConvertAuth, IssuedTokenResponse, IssueTokenRequest, ListTokensRequest, ListTokensResponse, publishToPromise, @@ -60,4 +60,11 @@ export class AuthClient { const call = callSingle(this.client.listTokens.bind(this.client), this.convert.listTokensResponse); return publishToPromise(readOnce(this.channel, call, request, this.retries)); } + + issueToken(req: IssueTokenRequest): Promise { + const request = this.convert.issueTokenRequest(req); + const call = callSingle(this.client.issueToken.bind(this.client), this.convert.issuedTokenResponse); + return publishToPromise(readOnce(this.channel, call, request, this.retries)); + } + } diff --git a/packages/node/src/wrapped/Factory.ts b/packages/node/src/wrapped/Factory.ts index 2e2983c..a4f735c 100644 --- a/packages/node/src/wrapped/Factory.ts +++ b/packages/node/src/wrapped/Factory.ts @@ -33,6 +33,8 @@ export const classFactory: MessageFactory = (id: string) => { return new auth_message_pb.AuthResponse(); case 'auth_pb.ListTokensRequest': return new auth_message_pb.ListTokensRequest(); + case 'auth_pb.IssueTokenRequest': + return new auth_message_pb.IssueTokenRequest(); // Address case 'address_message_pb.DescribeRequest': return new address_message_pb.DescribeRequest(); diff --git a/packages/web/src/wrapped/AuthClient.ts b/packages/web/src/wrapped/AuthClient.ts index f96aaf1..e7617e2 100644 --- a/packages/web/src/wrapped/AuthClient.ts +++ b/packages/web/src/wrapped/AuthClient.ts @@ -1,4 +1,5 @@ -import {AuthRequest, AuthResponse, +import { + AuthRequest, AuthResponse, CredentialsClient, ConvertAuth, ListTokensRequest, @@ -6,13 +7,14 @@ import {AuthRequest, AuthResponse, RefreshRequest, WhoIAmResponse, publishToPromise, - readOnce + readOnce, IssueTokenRequest, IssuedTokenResponse } from "@emeraldpay/api"; import {callPromise, WebChannel} from "../channel"; import * as auth_rpc from '../generated/AuthServiceClientPb'; import * as auth_pb from "../generated/auth_pb"; import {classFactory} from "./Factory"; import {CredentialsContext} from "../credentials"; +import {callSingle} from "@emeraldpay/api-node/lib/channel"; export class AuthClient { readonly client: auth_rpc.AuthClient; @@ -42,4 +44,10 @@ export class AuthClient { return publishToPromise(readOnce(this.channel, call, request, this.retries)); } + issueToken(req: IssueTokenRequest): Promise { + const request = this.convert.issueTokenRequest(req); + const call = callSingle(this.client.issueToken.bind(this.client), this.convert.issuedTokenResponse); + return publishToPromise(readOnce(this.channel, call, request, this.retries)); + } + } diff --git a/packages/web/src/wrapped/Factory.ts b/packages/web/src/wrapped/Factory.ts index dfeaa2d..7ce04aa 100644 --- a/packages/web/src/wrapped/Factory.ts +++ b/packages/web/src/wrapped/Factory.ts @@ -40,6 +40,9 @@ export const classFactory: MessageFactory = (id: string) => { if (id == 'auth_pb.ListTokensRequest') { return new auth_pb.ListTokensRequest(); } + if (id == 'auth_pb.IssueTokenRequest') { + return new auth_pb.IssueTokenRequest(); + } // Blockchain if (id == "blockchain_pb.NativeCallRequest") { return new blockchain_pb.NativeCallRequest();