From 10fd9aba3a8c3fe8491ba0359597ddd3eb8bdc0a Mon Sep 17 00:00:00 2001 From: Manfred Cheung Date: Fri, 15 Mar 2024 16:04:26 -0400 Subject: [PATCH] personal key auth (#156) add a new function "ClientPKey" to all projects which allows for authentication using personal keys --------- Co-authored-by: Leo Meyerovich --- CHANGELOG.md | 13 + projects/client-api-react/src/index.js | 3 +- projects/client-api/src/index.js | 44 +++- projects/js-upload-api/src/AbstractClient.ts | 165 ++++++++++++ projects/js-upload-api/src/Client.ts | 144 ++--------- projects/js-upload-api/src/ClientPKey.ts | 250 +++++++++++++++++++ projects/js-upload-api/src/Dataset.ts | 10 +- projects/js-upload-api/src/File.ts | 10 +- projects/js-upload-api/src/Privacy.ts | 4 +- projects/js-upload-api/src/index.ts | 2 + projects/js-upload-api/src/types.ts | 14 ++ projects/node-api/README.md | 2 +- projects/node-api/src/index.ts | 44 +++- 13 files changed, 569 insertions(+), 136 deletions(-) create mode 100644 projects/js-upload-api/src/AbstractClient.ts create mode 100644 projects/js-upload-api/src/ClientPKey.ts create mode 100644 projects/js-upload-api/src/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e4395..7c69eb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ## Dev +## 5.1.0 - 2024-03-15 + +### Added + +* **client-api**: Add API-key-based `ClientPKey` upload client +* **client-api-react**: Add API-key-based `ClientPKey` upload client +* **node-api**: Add API-key-based `ClientPKey` upload client +* **js-upload-api**: Add API-key-based `ClientPKey` upload client + +### Refactor + +* Factor out base class `AbstractClient` and add type export of `ClientType` + ## 5.0.2 - 2024-02-13 ### Added diff --git a/projects/client-api-react/src/index.js b/projects/client-api-react/src/index.js index 55d7314..5ec9dd7 100644 --- a/projects/client-api-react/src/index.js +++ b/projects/client-api-react/src/index.js @@ -9,9 +9,10 @@ import * as gAPI from '@graphistry/client-api'; import { ajax, catchError, first, forkJoin, map, of, switchMap, tap } from '@graphistry/client-api'; // avoid explicit rxjs dep import { bg } from './bg'; import { bindings, panelNames, calls } from './bindings.js'; -import { Client as ClientBase, selectionUpdates, subscribeLabels } from '@graphistry/client-api'; +import { Client as ClientBase, ClientPKey as ClientPKeyBase, selectionUpdates, subscribeLabels } from '@graphistry/client-api'; export const Client = ClientBase; +export const ClientPKey = ClientPKeyBase; //https://blog.logrocket.com/how-to-get-previous-props-state-with-react-hooks/ function usePrevious(value) { diff --git a/projects/client-api/src/index.js b/projects/client-api/src/index.js index fabcb5d..54e5970 100644 --- a/projects/client-api/src/index.js +++ b/projects/client-api/src/index.js @@ -3,7 +3,7 @@ import shallowEqual from 'shallowequal'; import { Model } from '@graphistry/falcor-model-rxjs'; import { PostMessageDataSource } from '@graphistry/falcor-socket-datasource'; import { $ref, $atom, $value } from '@graphistry/falcor-json-graph'; -import { Client as ClientBase, Dataset as DatasetBase, File as FileBase, EdgeFile as EdgeFileBase, NodeFile as NodeFileBase } from '@graphistry/js-upload-api'; +import { Client as ClientBase, ClientPKey as ClientPKeyBase, Dataset as DatasetBase, File as FileBase, EdgeFile as EdgeFileBase, NodeFile as NodeFileBase } from '@graphistry/js-upload-api'; const CLIENT_SUBSCRIPTION_API_VERSION = 1; @@ -16,7 +16,7 @@ export const NodeFile = NodeFileBase; //FIXME not generating jsdoc /** - * Class wrapping @graphistry/js-upload-api::Client for client->server File and Dataset uploads. + * Class wrapping @graphistry/js-upload-api::Client for client->server File and Dataset uploads using username and password authentication. * @global * @extends ClientBase */ @@ -45,7 +45,7 @@ export class Client extends ClientBase { clientProtocolHostname, version ) { - console.debug('new client', { username }, { password }, { protocol }, { host }, { clientProtocolHostname }, { version }); + // console.debug('new client', { username }, { password }, { protocol }, { host }, { clientProtocolHostname }, { version }); super( username, password, org, protocol, host, clientProtocolHostname, @@ -53,6 +53,44 @@ export class Client extends ClientBase { } } +/** + * Class wrapping @graphistry/js-upload-api::ClientPKey for client->server File and Dataset uploads using personal key authentication. + * @global + * @extends ClientPKeyBase + */ +export class ClientPKey extends ClientPKeyBase { + /** + * Create a Client + * @constructor + * @param {string} personalKeyId - Graphistry server personal key ID + * @param {string} personalKeySecret - Graphistry server personal key secret + * @param {string} org - Graphistry organization (optional) + * @param {string} [protocol='https'] - 'http' or 'https' for client->server upload communication + * @param {string} [host='hub.graphistry.com'] - Graphistry server hostname + * @param {clientProtocolHostname} clientProtocolHostname - Override URL base path shown in browsers. By default uses protocol/host combo, e.g., https://hub.graphistry.com + * + * For more examples, see @graphistry/node-api and @graphistry/js-upload-api docs + * + * @example **Authenticate against Graphistry Hub** + * ```javascript + * import { Client } from '@graphistry/client-api'; + * const client = new Client('my_personal_key_id', 'my_personal_key_secret'); + * ``` + */ + constructor( + personalKeyId, personalKeySecret, org = undefined, + protocol = 'https', host = 'hub.graphistry.com', + clientProtocolHostname, + version + ) { + // console.debug('new client', { personalKeyId }, { personalKeySecret }, { protocol }, { host }, { clientProtocolHostname }, { version }); + super( + personalKeyId, personalKeySecret, org, + protocol, host, clientProtocolHostname, + window.fetch.bind(window), version, '@graphistry/client-api'); + } +} + import { ajax, catchError, diff --git a/projects/js-upload-api/src/AbstractClient.ts b/projects/js-upload-api/src/AbstractClient.ts new file mode 100644 index 0000000..81f6884 --- /dev/null +++ b/projects/js-upload-api/src/AbstractClient.ts @@ -0,0 +1,165 @@ +export abstract class AbstractClient { + public readonly protocol: string; + public readonly host: string; + public readonly clientProtocolHostname: string; + public readonly agent: string; + public readonly version?: string; + public readonly org?: string; + + + protected _getAuthTokenPromise?: Promise; // undefined if not configured + + protected _token?: string; + + protected fetch: any; // eslint-disable-line @typescript-eslint/no-explicit-any + + /** + * @param org The organization to use (optional) + * @param protocol The protocol to use for the server during uploads: 'http' or 'https'. + * @param host The hostname of the server during uploads: defaults to 'hub.graphistry.com' + * @param clientProtocolHostname Base path to use inside the browser and when sharing public URLs: defaults to '{protocol}://{host}' + * @param fetch The fetch implementation to use: defaults to JavaScript fetch function + * @param version The version of the client library + * @param agent The agent name to use when communicating with the server + */ + constructor ( + org?: string, + protocol = 'https', host = 'hub.graphistry.com', + clientProtocolHostname?: string, + fetchFn: any = fetch, + version?: string, + agent = '@graphistry/js-upload-api', + ) { + this.org = org; + this.protocol = protocol; + this.host = host; + this.fetch = fetchFn; + this.version = version; + this.agent = agent; + this.clientProtocolHostname = clientProtocolHostname || `${protocol}://${host}`; + } + + /** + * + * See examples at top of file + * + * Set JWT token if already known + * + * @param token The token to use for authentication. + * @returns The client instance. + */ + public setToken(token: string) { + this._token = token; + return this; + } + + /** + * + * @returns Whether the current token is valid + * + */ + public authTokenValid() { + return Boolean(this._token); + } + + /** + * @internal + * Internal helper + * @param uri The URI to upload to. + * @param payload The payload to upload. + * @param baseHeaders Optionally override base header object to mix with auth header for the upload. + * @returns The response from the server. + */ + public async post(uri: string, payload: any, baseHeaders: any = undefined) { + console.debug('post', {uri, payload}); + const headers = await this.getSecureHeaders(baseHeaders); + console.debug('post', {headers}); + const response = await (await this.postToApi(uri, payload, headers)).json(); + console.debug('post response', {uri, payload, response}); + return response; + } + + public static isConfigurationValid(userId: string, secret: string, host: string) { + console.debug('isConfigurationValid', {userId, secret, host: host}); + return (userId || '') !== '' && (secret || '') !== '' && (host || '') !== ''; + } + + protected async getToApi(url: string, headers: any, baseUrl?: string) { + const resolvedFetch = this.fetch; + console.debug('getToApi', {url, headers}); + const response = await resolvedFetch((baseUrl ?? this.getBaseUrl()) + url, { + method: 'GET', + headers, + }); + console.debug('getToApi', {url, headers}); + return response; + } + + protected async postToApi(url: string, data: any, headers: any, baseUrl?: string) { + const resolvedFetch = this.fetch; + console.debug('postToApi', {url, data, headers}); + const response = await resolvedFetch((baseUrl ?? this.getBaseUrl()) + url, { // change this + method: 'POST', + headers, + body: + //TypedArray + ArrayBuffer.isView(data) && !(data instanceof DataView) ? data + : JSON.stringify(data), + }) + console.debug('postToApi', {url, data, headers, response}); + return response; + } + + protected getBaseHeaders() { + return ({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }); + } + + protected getBaseUrl() { + return `${this.protocol}://${this.host}/`; + } + + private async getSecureHeaders(baseHeaders?: any) { + const headers = baseHeaders || this.getBaseHeaders(); + const tok = await this.getAuthToken(); + console.debug('getSecureHeaders', {headers, tok}); + headers.Authorization = `Bearer ${tok}`; + console.debug('getSecureHeaders', {headers}); + return headers; + } + + public abstract isServerConfigured(): boolean; + + public abstract checkStale( + username: string, password: string, protocol: string, host: string, clientProtocolHostname?: string + ): boolean; + + /** + * Get the authentication token for the current user. + * By default, reuses current token if available. + * + * @param force If true, forces a new token to be generated; defaults to false + * + * @returns The authentication token + * + */ + protected abstract getAuthToken(): Promise; + + /** + * + * @param userId + * @param secret + * @param org + * @param protocol + * @param host + * @returns Promise for the authentication token + * + * Helper to fetch a token for a given user + * + */ + public abstract fetchToken( + userId: string, secret: string, org?: string, protocol?: string, host?: string + ): Promise +} \ No newline at end of file diff --git a/projects/js-upload-api/src/Client.ts b/projects/js-upload-api/src/Client.ts index 1070db8..1756a9d 100644 --- a/projects/js-upload-api/src/Client.ts +++ b/projects/js-upload-api/src/Client.ts @@ -1,7 +1,12 @@ +import { AbstractClient } from './AbstractClient.js'; + + +const AUTH_API_ENDPOINT = 'api/v2/auth/token/generate'; + /** * # Client examples * - * Authenticate against a Graphistry server and manage communications with it. + * Authenticate against a Graphistry server using a username and password, and manage communications with it. * * Different authentication modes may be desirable depending on the type of Graphistry server. * @@ -30,7 +35,7 @@ * @example **Authenticate against a private Graphistry server** * ```javascript * import { Client } from '@graphistry/node-api'; - * const client = new Client('my_username', 'my_password', 'http', 'my-ec2.aws.com:8080'); + * const client = new Client('my_username', 'my_password', '', 'http', 'my-ec2.aws.com:8080'); * ``` * *
@@ -39,7 +44,7 @@ * ```javascript * import { Client } from '@graphistry/node-api'; * const client = new Client( - * 'my_username', 'my_password', + * 'my_username', 'my_password', '', * 'http', '10.20.0.1:8080', * 'https://www.my-site.com' * ); @@ -51,7 +56,7 @@ * ```javascript * import { Client } from '@graphistry/node-api'; * const client = new Client( - * 'my_username', 'my_password', + * 'my_username', 'my_password', '', * 'http', 'nginx', * 'https://www.my-site.com' * ); @@ -64,24 +69,13 @@ * import { Client } from '@graphistry/node-api'; * const client = new Client(); * client.setToken('Bearer 123abc'); + * ``` */ -export class Client { + +export class Client extends AbstractClient { public readonly username: string; private _password: string; - public readonly protocol: string; - public readonly host: string; - public readonly clientProtocolHostname: string; - public readonly agent: string; - public readonly version?: string; - public readonly org?: string; - - - private _getAuthTokenPromise?: Promise; // undefined if not configured - - private _token?: string; - - private fetch: any; // eslint-disable-line @typescript-eslint/no-explicit-any /** * @@ -105,59 +99,22 @@ export class Client { version?: string, agent = '@graphistry/js-upload-api', ) { + super(org, protocol, host, clientProtocolHostname, fetch, version, agent); + this.username = username; this._password = password; - this.org = org; - this.protocol = protocol; - this.host = host; - this.fetch = fetch; - this.version = version; - this.agent = agent; - this.clientProtocolHostname = clientProtocolHostname || `${protocol}://${host}`; + if (this.isServerConfigured()) { this._getAuthTokenPromise = this.getAuthToken(); } } - /** - * - * See examples at top of file - * - * Set JWT token if already known - * - * @param token The token to use for authentication. - * @returns The client instance. - */ - public setToken(token: string) { - this._token = token; - return this; - } - - /** - * @internal - * Internal helper - * @param uri The URI to upload to. - * @param payload The payload to upload. - * @param baseHeaders Optionally override base header object to mix with auth header for the upload. - * @returns The response from the server. - */ - public async post(uri: string, payload: any, baseHeaders: any = undefined): Promise { // eslint-disable-line @typescript-eslint/no-explicit-any - console.debug('post', {uri, payload}); - const headers = await this.getSecureHeaders(baseHeaders); - console.debug('post', {headers}); - const response = await this.postToApi(uri, payload, headers); - console.debug('post response', {uri, payload, response}); - return response; - } - - - - public isServerConfigured(): boolean { + public isServerConfigured() { console.debug('isServerConfigured', {username: this.username, _password: this._password, host: this.host}); return (this.username || '') !== '' && (this._password || '') !== '' && (this.host || '') !== ''; } - public checkStale(username: string, password: string, protocol: string, host: string, clientProtocolHostname?: string): boolean { + public checkStale(username: string, password: string, protocol: string, host: string, clientProtocolHostname?: string) { if (this.username !== username) { console.debug('username changed', {currentUsername: this.username, newUsername: username}, this); return true; @@ -181,11 +138,6 @@ export class Client { return false; } - public static isConfigurationValid(username: string, password: string, host: string): boolean { - console.debug('isConfigurationValid', {username: username, password: password, host: host}); - return (username || '') !== '' && (password || '') !== '' && (host || '') !== ''; - } - /** * Get the authentication token for the current user. * By default, reuses current token if available. @@ -195,7 +147,7 @@ export class Client { * @returns The authentication token * */ - private async getAuthToken(force = false): Promise { + protected async getAuthToken(force = false) { if (!force && this.authTokenValid()) { return this._token || ''; // workaround ts not recognizing that _token is set } @@ -213,15 +165,16 @@ export class Client { console.debug('getAuthToken', {username: this.username, _password: this._password, host: this.host}); - const response = await this.postToApi( - 'api/v2/auth/token/generate', + let response = await this.postToApi( + AUTH_API_ENDPOINT, { username: this.username, password: this._password, ...(this.org ? {org_name: this.org} : {}), }, this.getBaseHeaders(), - ) + ); + response = await response.json(); const tok : string = response.token; this._token = tok; @@ -249,8 +202,8 @@ export class Client { public async fetchToken( username: string, password: string, org?: string, protocol = 'https', host = 'hub.graphistry.com' ): Promise { - return (await this.postToApi( - 'api/v2/auth/token/generate', + let response = await this.postToApi( + AUTH_API_ENDPOINT, { username: username, password: password, @@ -258,51 +211,8 @@ export class Client { }, this.getBaseHeaders(), `${protocol}://${host}/` - )).token; - } - - /** - * - * @returns Whether the current token is valid - * - */ - public authTokenValid(): boolean { - const out = !!this._token; - return out; - } - - private async postToApi(url: string, data: any, headers: any, baseUrl?: string): Promise { // eslint-disable-line @typescript-eslint/no-explicit-any - const resolvedFetch = this.fetch; - console.debug('postToApi', {url, data, headers}); - const response = await resolvedFetch((baseUrl ?? this.getBaseUrl()) + url, { // change this - method: 'POST', - headers, - body: - //TypedArray - ArrayBuffer.isView(data) && !(data instanceof DataView) ? data - : JSON.stringify(data), - }) - console.debug('postToApi', {url, data, headers, response}); - return await response.json(); - } - - private getBaseHeaders(): any { // eslint-disable-line @typescript-eslint/no-explicit-any - return ({ - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }); - } - - private async getSecureHeaders(baseHeaders: any = undefined): Promise { // eslint-disable-line @typescript-eslint/no-explicit-any - const headers = baseHeaders || this.getBaseHeaders(); - const tok = await this.getAuthToken(); - console.debug('getSecureHeaders', {headers, tok}); - headers.Authorization = `Bearer ${tok}`; - console.debug('getSecureHeaders', {headers}); - return headers; - } - - private getBaseUrl(): string { - return `${this.protocol}://${this.host}/`; + ); + response = await response.json(); + return response.token; } } \ No newline at end of file diff --git a/projects/js-upload-api/src/ClientPKey.ts b/projects/js-upload-api/src/ClientPKey.ts new file mode 100644 index 0000000..6461f98 --- /dev/null +++ b/projects/js-upload-api/src/ClientPKey.ts @@ -0,0 +1,250 @@ +import { AbstractClient } from './AbstractClient.js'; + + +const AUTH_API_ENDPOINT = 'api/v2/auth/pkey/jwt/'; + +/** + * # Client examples + * + * Authenticate against a Graphistry server using a personal key id and secret, and manage communications with it. + * + * Different authentication modes may be desirable depending on the type of Graphistry server. + * + *
+ * + * --- + * + *
+ * + * @example **Authenticate against Graphistry Hub for a personal account** + * ```javascript + * import { Client } from '@graphistry/node-api'; + * const client = new Client('my_personal_key_id', 'my_personal_key_secret'); + * ``` + * + *
+ * + * @example **Authenticate against an org in Graphistry Hub** + * ```javascript + * import { Client } from '@graphistry/node-api'; + * const client = new Client('my_personal_key_id', 'my_personal_key_secret', 'my_org'); + * ``` + * + *
+ * + * @example **Authenticate against a private Graphistry server** + * ```javascript + * import { Client } from '@graphistry/node-api'; + * const client = new Client('my_personal_key_id', 'my_personal_key_secret', '', 'http', 'my-ec2.aws.com:8080'); + * ``` + * + *
+ * +* @example **Upload via internal IP but publish urls via a public domain** + * ```javascript + * import { Client } from '@graphistry/node-api'; + * const client = new Client( + * 'my_personal_key_id', 'my_personal_key_secret', '', + * 'http', '10.20.0.1:8080', + * 'https://www.my-site.com' + * ); + * ``` + * + *
+ * + * @example **Upload through the local docker network but publish urls via a public domain** + * ```javascript + * import { Client } from '@graphistry/node-api'; + * const client = new Client( + * 'my_personal_key_id', 'my_personal_key_secret', '', + * 'http', 'nginx', + * 'https://www.my-site.com' + * ); + * ``` + * + *
+ * + * @example **Create a client with an externally-provided JWT token** + * ```javascript + * import { Client } from '@graphistry/node-api'; + * const client = new Client(); + * client.setToken('Bearer 123abc'); + * ``` + */ + +export class ClientPKey extends AbstractClient { + + public readonly personalKeyId: string; + private _personalKeySecret: string; + + /** + * + * See examples at top of file + * + * @param personalKeyId The personal key id to authenticate with. + * @param personalKeySecret The personal key secret to authenticate with. + * @param org The organization to use (optional) + * @param protocol The protocol to use for the server during uploads: 'http' or 'https'. + * @param host The hostname of the server during uploads: defaults to 'hub.graphistry.com' + * @param clientProtocolHostname Base path to use inside the browser and when sharing public URLs: defaults to '{protocol}://{host}' + * @param fetch The fetch implementation to use + * @param version The version of the client library + * @param agent The agent name to use when communicating with the server + */ + constructor ( + personalKeyId: string, personalKeySecret: string, org?: string, + protocol = 'https', host = 'hub.graphistry.com', + clientProtocolHostname?: string, + fetch?: any, + version?: string, + agent = '@graphistry/js-upload-api', + ) { + super(org, protocol, host, clientProtocolHostname, fetch, version, agent); + + this.personalKeyId = personalKeyId; + this._personalKeySecret = personalKeySecret; + + if (this.isServerConfigured()) { + this._getAuthTokenPromise = this.getAuthToken(); + } + } + + public isServerConfigured() { + console.debug('isServerConfigured', {personalKeyId: this.personalKeyId, _personalKeySecret: this._personalKeySecret, host: this.host}); + return (this.personalKeyId || '') !== '' && (this._personalKeySecret || '') !== '' && (this.host || '') !== ''; + } + + public checkStale(personalKeyId: string, personalKeySecret: string, protocol: string, host: string, clientProtocolHostname?: string) { + if (this.personalKeyId !== personalKeyId) { + console.debug('personalKeyId changed', {currentPersonalKeyId: this.personalKeyId, newPersonalKeyId: personalKeyId}, this); + return true; + } + if (this._personalKeySecret !== personalKeySecret) { + console.debug('personalKeySecret changed', {currentPersonalKeySecret: this._personalKeySecret, newPersonalKeySecret: personalKeySecret}, this); + return true; + } + if (this.protocol !== protocol) { + console.debug('protocol changed', {currentProtocol: this.protocol, newProtocol: protocol}, this); + return true; + } + if (this.host !== host) { + console.debug('host changed', {currentHost: this.host, newHost: host}, this); + return true; + } + if (this.clientProtocolHostname !== clientProtocolHostname) { + console.debug('clientProtocolHostname changed', {currentClientProtocolHostname: this.clientProtocolHostname, newClientProtocolHostname: clientProtocolHostname}, this); + return true; + } + return false; + } + + private getPKeyString(id: string, secret: string) { + return `PersonalKey ${id}:${secret}`; + } + + /** + * Get the authentication token for the current user. + * By default, reuses current token if available. + * + * @param force If true, forces a new token to be generated; defaults to false + * + * @returns The authentication token + * + */ + protected async getAuthToken(force = false) { + if (!force && this.authTokenValid()) { + return this._token || ''; // workaround ts not recognizing that _token is set + } + + //Throw exception if invalid personalKeyId or personalKeySecret + if (!this.isServerConfigured()) { + console.debug('current config', {personalKeyId: this.personalKeyId, _personalKeySecret: this._personalKeySecret, host: this.host}); + throw new Error('Invalid personalKeyId or personalKeySecret'); + } + + if (!force && this._getAuthTokenPromise) { + console.debug('reusing outstanding auth promise'); + return await this._getAuthTokenPromise; + } + + console.debug('getAuthToken', {personalKeyId: this.personalKeyId, personalKeySecret: this._personalKeySecret, host: this.host}); + + let response = await this.postToApi( + AUTH_API_ENDPOINT, + { + ...(this.org ? {org_name: this.org} : {}), + }, + { + "Authorization": this.getPKeyString(this.personalKeyId, this._personalKeySecret), + ...this.getBaseHeaders(), + } + ); + // fallback to personal-only GET pkey auth if 405 (Method Not Allowed) + if(response.status === 405) { + if(this.org) { + console.warn('Host does not support org auth via PKey, use username/password auth instead'); + } + response = await this.getToApi( + AUTH_API_ENDPOINT, + { + "Authorization": this.getPKeyString(this.personalKeyId, this._personalKeySecret), + } + ); + } + response = await response.json(); + + const tok : string = response.token; + this._token = tok; + + if (!this.authTokenValid()) { + console.error('auth token failure', {response, personalKeyId: this.personalKeyId, host: this.host}); + throw new Error({'error': 'Auth token failure', ...(response||{})}); + } + + return tok; + } + + /** + * + * @param personalKeyId + * @param personalKeySecret + * @param org + * @param protocol + * @param host + * @returns Promise for the authentication token + * + * Helper to fetch a token for a given user + * + */ + public async fetchToken( + personalKeyId: string, personalKeySecret: string, org?: string, protocol = 'https', host = 'hub.graphistry.com' + ): Promise { + let response = await this.postToApi( + AUTH_API_ENDPOINT, + { + ...(org ? {org_name: org} : {}), + }, + { + "Authorization": this.getPKeyString(personalKeyId, personalKeySecret), + ...this.getBaseHeaders(), + }, + `${protocol}://${host}/` + ); + // fallback to personal-only GET pkey auth if 405 (Method Not Allowed) + if(response.status === 405) { + if(org) { + console.warn('Host does not support org auth via PKey, use username/password auth instead'); + } + response = await this.getToApi( + AUTH_API_ENDPOINT, + { + "Authorization": this.getPKeyString(personalKeyId, personalKeySecret), + }, + `${protocol}://${host}/` + ); + } + response = await response.json(); + + return response.token; + } +} \ No newline at end of file diff --git a/projects/js-upload-api/src/Dataset.ts b/projects/js-upload-api/src/Dataset.ts index 789b8f4..51eca11 100644 --- a/projects/js-upload-api/src/Dataset.ts +++ b/projects/js-upload-api/src/Dataset.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Client } from './Client.js'; +import { ClientType } from './types.js'; import { File, FileType, EdgeFile, NodeFile } from './File.js'; import { Mode, ModeAction, Privacy } from './Privacy.js'; @@ -297,7 +297,7 @@ export class Dataset { * @param client Client object * @returns Promise that resolves when the dataset is uploaded */ - public async upload(client: Client): Promise { + public async upload(client: ClientType): Promise { if (!client) { throw new Error('No client provided'); @@ -341,7 +341,7 @@ export class Dataset { //////////////////////////////////////////////////////////////////////////////// - private async createDataset(client: Client, bindings: Record): Promise { + private async createDataset(client: ClientType, bindings: Record): Promise { this.fillMetadata(bindings, client); const dataJsonResults = await client.post('api/v2/upload/datasets/', bindings); this._createDatasetResponse = dataJsonResults; @@ -389,7 +389,7 @@ export class Dataset { /////////////////////////////////////////////////////////////////////////////// - private fillMetadata(data: any, client: Client): void{ + private fillMetadata(data: any, client: ClientType): void{ if (!data) { throw new Error('No data to fill metadata; call setData() first or provide to File constructor'); } @@ -432,7 +432,7 @@ export class Dataset { * @throws Error if server call fails */ public async privacy( - client: Client, + client: ClientType, mode: Mode = 'private', modeAction?: ModeAction, invitedUsers: string[] = [], diff --git a/projects/js-upload-api/src/File.ts b/projects/js-upload-api/src/File.ts index cab880c..a1983f6 100644 --- a/projects/js-upload-api/src/File.ts +++ b/projects/js-upload-api/src/File.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Client } from './Client.js'; +import { ClientType } from './types.js'; /** @@ -209,7 +209,7 @@ export class File { * @param force If true, will force upload even if file has already been uploaded * @returns Promise that resolves to the uploaded File object when it completes uploading */ - public async upload(client : Client, force = false): Promise { + public async upload(client : ClientType, force = false): Promise { if (!client) { throw new Error('No client provided'); } @@ -242,7 +242,7 @@ export class File { * @param force If true, will force creation of a new ID even if file has already been uploaded * @returns */ - public async createFile(client : Client, force = false): Promise { + public async createFile(client : ClientType, force = false): Promise { if (!force && this._fileCreated) { console.debug('File already created, skipping'); return this._fileCreated; @@ -275,7 +275,7 @@ export class File { * @param force If true, will force upload even if file has already been uploaded * @returns */ - public async uploadData(client : Client, force = false): Promise { + public async uploadData(client : ClientType, force = false): Promise { if (!force && this._fileUploaded) { return this._fileUploaded; } @@ -312,7 +312,7 @@ export class File { /////////////////////////////////////////////////////////////////////////////// - private fillMetadata(data: any, client: Client): void { + private fillMetadata(data: any, client: ClientType): void { if (!data) { throw new Error('No data to fill metadata; call setData() first or provide to File constructor'); } diff --git a/projects/js-upload-api/src/Privacy.ts b/projects/js-upload-api/src/Privacy.ts index b7374ae..fe3479a 100644 --- a/projects/js-upload-api/src/Privacy.ts +++ b/projects/js-upload-api/src/Privacy.ts @@ -1,4 +1,4 @@ -import { Client } from './Client.js'; +import { ClientType } from './types.js'; /** @@ -144,7 +144,7 @@ export class Privacy { * @throws Error if the upload fails * */ - public async upload(client: Client): Promise { + public async upload(client: ClientType): Promise { if (!client) { throw new Error('No client provided'); diff --git a/projects/js-upload-api/src/index.ts b/projects/js-upload-api/src/index.ts index dec99b8..20d40fe 100644 --- a/projects/js-upload-api/src/index.ts +++ b/projects/js-upload-api/src/index.ts @@ -1,5 +1,7 @@ "use strict"; export { Client } from "./Client.js"; +export { ClientPKey } from "./ClientPKey.js"; +export { ClientType } from "./types.js"; export { FileType, File, EdgeFile, NodeFile } from "./File.js"; export { Dataset } from "./Dataset.js"; export { Privacy } from "./Privacy.js"; diff --git a/projects/js-upload-api/src/types.ts b/projects/js-upload-api/src/types.ts new file mode 100644 index 0000000..cec857b --- /dev/null +++ b/projects/js-upload-api/src/types.ts @@ -0,0 +1,14 @@ +import { Client } from "./Client.js"; +import { ClientPKey } from "./ClientPKey.js"; + +/** + * @internal + * + *
+ * + * ## ClientType + * + * A type including clients with all available authentication methods + * + */ +export type ClientType = Client | ClientPKey; \ No newline at end of file diff --git a/projects/node-api/README.md b/projects/node-api/README.md index 55e56af..8479315 100644 --- a/projects/node-api/README.md +++ b/projects/node-api/README.md @@ -48,7 +48,7 @@ const dataset = new Dataset({ name: 'testdata', }, edgesFile, nodesFile); -await dataset.upload(); +await dataset.upload(client); console.info(`View at ${dataset.datasetID} at ${dataset.datasetURL}`); ``` diff --git a/projects/node-api/src/index.ts b/projects/node-api/src/index.ts index ca0b8ca..58e5693 100644 --- a/projects/node-api/src/index.ts +++ b/projects/node-api/src/index.ts @@ -1,4 +1,4 @@ -import { Client as ClientBase } from '@graphistry/js-upload-api'; +import { Client as ClientBase, ClientPKey as ClientPKeyBase } from '@graphistry/js-upload-api'; import { version as VERSION } from './version.js'; import fetch from 'node-fetch-commonjs'; @@ -41,7 +41,7 @@ export class Client extends ClientBase { clientProtocolHostname?: string, version: string = VERSION ) { - console.debug('new client', { username }, { password }, { protocol }, { host }, { clientProtocolHostname }, { version }); + // console.debug('new client', { username }, { password }, { protocol }, { host }, { clientProtocolHostname }, { version }); super( username, password, org, protocol, host, clientProtocolHostname, @@ -50,3 +50,43 @@ export class Client extends ClientBase { '@graphistry/node-api'); } } + +/** + * Class wrapping @graphistry/js-upload-api::ClientPKey for client->server File and Dataset uploads using personal key authentication. + * @global + * @extends ClientPKeyBase + */ +export class ClientPKey extends ClientPKeyBase { + /** + * Create a Client + * @constructor + * @param {string} personalKeyId - Graphistry server personal key ID + * @param {string} personalKeySecret - Graphistry server personal key secret + * @param {string} org - Graphistry organization (optional) + * @param {string} [protocol='https'] - 'http' or 'https' for client->server upload communication + * @param {string} [host='hub.graphistry.com'] - Graphistry server hostname + * @param {clientProtocolHostname} clientProtocolHostname - Override URL base path shown in browsers. By default uses protocol/host combo, e.g., https://hub.graphistry.com + * + * For more examples, see @graphistry/node-api and @graphistry/js-upload-api docs + * + * @example **Authenticate against Graphistry Hub** + * ```javascript + * import { Client } from '@graphistry/client-api'; + * const client = new Client('my_personal_key_id', 'my_personal_key_secret'); + * ``` + */ + constructor( + personalKeyId: string, personalKeySecret: string, org?: string, + protocol = 'https', host = 'hub.graphistry.com', + clientProtocolHostname?: string, + version: string = VERSION + ) { + // console.debug('new client', { personalKeyId }, { personalKeySecret }, { protocol }, { host }, { clientProtocolHostname }, { version }); + super( + personalKeyId, personalKeySecret, org, + protocol, host, clientProtocolHostname, + fetch, + version, + '@graphistry/node-api'); + } +}