From 1972413e1511593aff53a88b4cf2d987fd2af2a6 Mon Sep 17 00:00:00 2001 From: Patrick Arlt Date: Thu, 23 Jan 2025 12:00:12 -0800 Subject: [PATCH] feat: make API keys work with all portal methods (#1186) * feat: make API keys work with all portal methods * chore: configure gitignore for demo * Apply suggestions from code review Co-authored-by: Gavin Rehkemper --------- Co-authored-by: Gavin Rehkemper --- demos/personal-api-keys/.gitignore | 1 + demos/personal-api-keys/README.md | 9 ++ demos/personal-api-keys/config.template.ts | 1 + demos/personal-api-keys/index.ts | 17 +++ demos/personal-api-keys/package.json | 21 ++++ demos/personal-api-keys/tsconfig.json | 104 ++++++++++++++++ package-lock.json | 72 +++++------ .../src/shared/types/apiKeyType.ts | 14 +-- .../src/shared/types/appType.ts | 14 +-- .../src/shared/types/oAuthType.ts | 18 +-- packages/arcgis-rest-portal/src/index.ts | 1 + .../arcgis-rest-portal/src/items/helpers.ts | 6 +- .../arcgis-rest-portal/src/sharing/access.ts | 10 +- .../arcgis-rest-portal/src/sharing/helpers.ts | 27 +++-- .../src/sharing/share-item-with-group.ts | 15 +-- .../src/sharing/unshare-item-with-group.ts | 13 +- .../src/users/get-user-tags.ts | 8 +- .../src/users/get-user-url.ts | 1 + .../arcgis-rest-portal/src/users/get-user.ts | 15 ++- .../src/users/invitation.ts | 18 +-- .../src/users/notification.ts | 9 +- .../arcgis-rest-portal/src/users/update.ts | 5 +- .../src/util/determine-username.ts | 35 ++++++ .../test/sharing/helpers.test.ts | 37 +++++- .../test/util/determine-username.test.ts | 68 +++++++++++ .../arcgis-rest-request/src/ApiKeyManager.ts | 17 ++- .../src/ApplicationCredentialsManager.ts | 8 +- .../src/ArcGISIdentityManager.ts | 89 ++------------ .../src/AuthenticationManagerBase.ts | 113 ++++++++++++++++++ .../src/authenticated-request-options.ts | 5 +- .../src/utils/IAuthenticationManager.ts | 11 ++ .../arcgis-rest-request/test/ApiKey.test.ts | 84 ++++++++++++- 32 files changed, 655 insertions(+), 211 deletions(-) create mode 100644 demos/personal-api-keys/.gitignore create mode 100644 demos/personal-api-keys/README.md create mode 100644 demos/personal-api-keys/config.template.ts create mode 100644 demos/personal-api-keys/index.ts create mode 100644 demos/personal-api-keys/package.json create mode 100644 demos/personal-api-keys/tsconfig.json create mode 100644 packages/arcgis-rest-portal/src/util/determine-username.ts create mode 100644 packages/arcgis-rest-portal/test/util/determine-username.test.ts create mode 100644 packages/arcgis-rest-request/src/AuthenticationManagerBase.ts diff --git a/demos/personal-api-keys/.gitignore b/demos/personal-api-keys/.gitignore new file mode 100644 index 0000000000..1b8afd087b --- /dev/null +++ b/demos/personal-api-keys/.gitignore @@ -0,0 +1 @@ +config.ts \ No newline at end of file diff --git a/demos/personal-api-keys/README.md b/demos/personal-api-keys/README.md new file mode 100644 index 0000000000..a7eebd81f7 --- /dev/null +++ b/demos/personal-api-keys/README.md @@ -0,0 +1,9 @@ +# Using API Keys for Portal Functions + +When an API key is created with personal scopes it returns user information in the `portal/self` and `community/self` endpoints. This information includes the username which is used to construct various request URLs. + +To run this example: + +1. Create an API key with the privileges to create, read and update items. +2. Copy `config.template.ts` to `config.ts` and replace the API key with the API key from step 1. +3. Run `npm install` and `npm start` \ No newline at end of file diff --git a/demos/personal-api-keys/config.template.ts b/demos/personal-api-keys/config.template.ts new file mode 100644 index 0000000000..03c24426ba --- /dev/null +++ b/demos/personal-api-keys/config.template.ts @@ -0,0 +1 @@ +export const ApiKey = "YOUR_API_KEY"; diff --git a/demos/personal-api-keys/index.ts b/demos/personal-api-keys/index.ts new file mode 100644 index 0000000000..7126a2672a --- /dev/null +++ b/demos/personal-api-keys/index.ts @@ -0,0 +1,17 @@ +import { ApiKeyManager } from "@esri/arcgis-rest-request"; +import { getUserContent } from "@esri/arcgis-rest-portal"; +import { ApiKey } from "./config.js"; + +const personalApiKey = ApiKeyManager.fromKey({ + key: ApiKey +}); + +await getUserContent({ + authentication: personalApiKey +}).then((response) => { + console.log(response); +}); + +const username = await personalApiKey.getUsername(); + +console.log({ username }); diff --git a/demos/personal-api-keys/package.json b/demos/personal-api-keys/package.json new file mode 100644 index 0000000000..7945301d81 --- /dev/null +++ b/demos/personal-api-keys/package.json @@ -0,0 +1,21 @@ +{ + "private": true, + "name": "personal-api-keys", + "version": "1.0.0", + "description": "", + "license": "Apache-2.0", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node --loader ts-node/esm index.ts" + }, + "dependencies": { + "@esri/arcgis-rest-portal": "^4.0.0", + "@esri/arcgis-rest-request": "^4.0.0" + }, + "devDependencies": { + "ts-node": "^10.7.0", + "typescript": "^4.6.2" + }, + "author": "" +} diff --git a/demos/personal-api-keys/tsconfig.json b/demos/personal-api-keys/tsconfig.json new file mode 100644 index 0000000000..ef1ce98174 --- /dev/null +++ b/demos/personal-api-keys/tsconfig.json @@ -0,0 +1,104 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Projects */ + // "incremental": true, /* Enable incremental compilation */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ + // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + + /* Modules */ + "module": "esnext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "resolveJsonModule": true, /* Enable importing .json files */ + // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ + // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ + // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "ts-node": { + "esm": true + } +} diff --git a/package-lock.json b/package-lock.json index d0fbf87816..26ecab3976 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", "@semantic-release/npm": "^8.0.0", - "@skypack/package-check": "^0.2.2", "@types/fetch-mock": "^7.3.5", "@types/jasmine": "^2.8.2", "@types/node": "^12.20.4", @@ -403,6 +402,31 @@ "parcel": "^2.0.0" } }, + "demos/personal-api-keys": { + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@esri/arcgis-rest-portal": "^4.0.0", + "@esri/arcgis-rest-request": "^4.0.0" + }, + "devDependencies": { + "ts-node": "^10.7.0", + "typescript": "^4.6.2" + } + }, + "demos/personal-api-keys/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "demos/snowpack": { "name": "@esri/arcgis-rest-demo-snowpack", "version": "3.3.0", @@ -10531,34 +10555,6 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@skypack/package-check": { - "version": "0.2.2", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.3", - "yargs-parser": "^20.2.3" - }, - "bin": { - "package-check": "index.bin.js" - } - }, - "node_modules/@skypack/package-check/node_modules/kleur": { - "version": "4.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@skypack/package-check/node_modules/yargs-parser": { - "version": "20.2.9", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/@socket.io/base64-arraybuffer": { "version": "1.0.2", "dev": true, @@ -30053,6 +30049,10 @@ "dev": true, "license": "MIT" }, + "node_modules/personal-api-keys": { + "resolved": "demos/personal-api-keys", + "link": true + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -41001,7 +41001,7 @@ }, "packages/arcgis-rest-demographics": { "name": "@esri/arcgis-rest-demographics", - "version": "4.0.2", + "version": "4.0.3", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" @@ -41018,7 +41018,7 @@ }, "packages/arcgis-rest-developer-credentials": { "name": "@esri/arcgis-rest-developer-credentials", - "version": "1.0.0", + "version": "1.0.1", "license": "Apache-2.0", "dependencies": { "@esri/arcgis-rest-portal": "^4.2.0", @@ -41034,7 +41034,7 @@ }, "packages/arcgis-rest-feature-service": { "name": "@esri/arcgis-rest-feature-service", - "version": "4.0.5", + "version": "4.1.0", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" @@ -41086,7 +41086,7 @@ }, "packages/arcgis-rest-geocoding": { "name": "@esri/arcgis-rest-geocoding", - "version": "4.0.2", + "version": "4.0.3", "license": "Apache-2.0", "dependencies": { "@terraformer/arcgis": "^2.0.7", @@ -41105,7 +41105,7 @@ }, "packages/arcgis-rest-places": { "name": "@esri/arcgis-rest-places", - "version": "1.0.1", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" @@ -41122,7 +41122,7 @@ }, "packages/arcgis-rest-portal": { "name": "@esri/arcgis-rest-portal", - "version": "4.4.0", + "version": "4.4.1", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" @@ -41139,7 +41139,7 @@ }, "packages/arcgis-rest-request": { "name": "@esri/arcgis-rest-request", - "version": "4.2.1", + "version": "4.2.3", "license": "Apache-2.0", "dependencies": { "@esri/arcgis-rest-fetch": "^4.0.0", diff --git a/packages/arcgis-rest-developer-credentials/src/shared/types/apiKeyType.ts b/packages/arcgis-rest-developer-credentials/src/shared/types/apiKeyType.ts index ca2e5d5691..8e80794805 100644 --- a/packages/arcgis-rest-developer-credentials/src/shared/types/apiKeyType.ts +++ b/packages/arcgis-rest-developer-credentials/src/shared/types/apiKeyType.ts @@ -1,5 +1,5 @@ import { - ArcGISIdentityManager, + IAuthenticationManager, IRequestOptions, ISpatialReference } from "@esri/arcgis-rest-request"; @@ -36,9 +36,9 @@ export interface ICreateApiKeyOptions */ export interface IGetApiKeyOptions extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which API key to be retrieved. */ @@ -71,9 +71,9 @@ export interface IApiKeyResponse extends IApiKeyInfo { */ export interface IUpdateApiKeyOptions extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which API key will be updated. */ @@ -93,9 +93,9 @@ export interface IUpdateApiKeyOptions extends Omit { */ export interface IDeleteApiKeyOption extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which API key to be removed. */ diff --git a/packages/arcgis-rest-developer-credentials/src/shared/types/appType.ts b/packages/arcgis-rest-developer-credentials/src/shared/types/appType.ts index 05125d0f0e..2e4fba8ccb 100644 --- a/packages/arcgis-rest-developer-credentials/src/shared/types/appType.ts +++ b/packages/arcgis-rest-developer-credentials/src/shared/types/appType.ts @@ -1,6 +1,6 @@ import { IRequestOptions, - ArcGISIdentityManager + IAuthenticationManager } from "@esri/arcgis-rest-request"; import { UnixTime } from "@esri/arcgis-rest-portal"; import { Privileges } from "../enum/privileges.js"; @@ -35,9 +35,9 @@ export interface IRegisterAppOptions extends Omit { */ privileges: Array; /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; } /** @@ -45,9 +45,9 @@ export interface IRegisterAppOptions extends Omit { */ export interface IGetAppInfoOptions extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which app to be retrieved. */ @@ -99,9 +99,9 @@ export interface IApp */ export interface IUnregisterAppOptions extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which app to be unregistered. */ diff --git a/packages/arcgis-rest-developer-credentials/src/shared/types/oAuthType.ts b/packages/arcgis-rest-developer-credentials/src/shared/types/oAuthType.ts index 400a9bdbf1..af8f8a0ec9 100644 --- a/packages/arcgis-rest-developer-credentials/src/shared/types/oAuthType.ts +++ b/packages/arcgis-rest-developer-credentials/src/shared/types/oAuthType.ts @@ -1,6 +1,6 @@ import { IItem } from "@esri/arcgis-rest-portal"; import { - ArcGISIdentityManager, + IAuthenticationManager, IRequestOptions, ISpatialReference } from "@esri/arcgis-rest-request"; @@ -14,9 +14,9 @@ export interface ICreateOAuthAppOption extends Omit { */ redirect_uris?: string[]; /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; title: string; owner?: string; typeKeywords?: string[]; @@ -37,9 +37,9 @@ export interface ICreateOAuthAppOption extends Omit { */ export interface IGetOAuthAppOptions extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which OAuth2.0 app to be retrieved. */ @@ -51,9 +51,9 @@ export interface IGetOAuthAppOptions extends Omit { */ export interface IUpdateOAuthOptions extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which OAuth2.0 app to be updated. */ @@ -91,9 +91,9 @@ export interface IOAuthApp extends IOAuthAppInfo { */ export interface IDeleteOAuthAppOption extends Omit { /** - * {@linkcode ArcGISIdentityManager} authentication. + * {@linkcode IAuthenticationManager} authentication. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; /** * itemId of which OAuth2.0 app to be removed. */ diff --git a/packages/arcgis-rest-portal/src/index.ts b/packages/arcgis-rest-portal/src/index.ts index f25f31b0d0..febf08dd05 100644 --- a/packages/arcgis-rest-portal/src/index.ts +++ b/packages/arcgis-rest-portal/src/index.ts @@ -49,6 +49,7 @@ export * from "./sharing/helpers.js"; export * from "./services/get-unique-service-name.js"; export * from "./services/is-service-name-available.js"; +export * from "./util/determine-username.js"; export * from "./util/get-portal.js"; export * from "./util/get-portal-settings.js"; export * from "./util/get-portal-url.js"; diff --git a/packages/arcgis-rest-portal/src/items/helpers.ts b/packages/arcgis-rest-portal/src/items/helpers.ts index ceced44e6e..d2ed4596e0 100644 --- a/packages/arcgis-rest-portal/src/items/helpers.ts +++ b/packages/arcgis-rest-portal/src/items/helpers.ts @@ -17,7 +17,7 @@ export interface IUserItemOptions extends IUserRequestOptions { */ id: string; /** - * Item owner username. If not present, `authentication.username` is utilized. + * Item owner username. If not present, `authentication.getUsername()` is utilized. */ owner?: string; } @@ -28,7 +28,7 @@ export interface IFolderIdOptions extends IUserRequestOptions { */ folderId: string; /** - * Item owner username. If not present, `authentication.username` is utilized. + * Item owner username. If not present, `authentication.getUsername()` is utilized. */ owner?: string; } @@ -141,7 +141,7 @@ export interface IRemoveItemResourceOptions extends IUserItemOptions { export interface ICreateUpdateItemOptions extends IUserRequestOptions { /** - * The owner of the item. If this property is not present, `item.owner` will be passed, or lastly `authentication.username`. + * The owner of the item. If this property is not present, `item.owner` will be passed, or lastly `authentication.getUsername()`. */ owner?: string; /** diff --git a/packages/arcgis-rest-portal/src/sharing/access.ts b/packages/arcgis-rest-portal/src/sharing/access.ts index 891cc0341b..ffb8cfa9ae 100644 --- a/packages/arcgis-rest-portal/src/sharing/access.ts +++ b/packages/arcgis-rest-portal/src/sharing/access.ts @@ -34,12 +34,12 @@ export interface ISetAccessOptions extends ISharingOptions { * @param requestOptions - Options for the request. * @returns A Promise that will resolve with the data from the response. */ -export function setItemAccess( +export async function setItemAccess( requestOptions: ISetAccessOptions ): Promise { - const url = getSharingUrl(requestOptions); - - if (isItemOwner(requestOptions)) { + const username = await requestOptions.authentication.getUsername(); + const url = getSharingUrl(requestOptions, username); + if (isItemOwner(requestOptions, username)) { // if the user owns the item, proceed return updateItemAccess(url, requestOptions); } else { @@ -50,7 +50,7 @@ export function setItemAccess( } else { // if neither, updating the sharing isnt possible throw Error( - `This item can not be shared by ${requestOptions.authentication.username}. They are neither the item owner nor an organization admin.` + `This item can not be shared by ${username}. They are neither the item owner nor an organization admin.` ); } }); diff --git a/packages/arcgis-rest-portal/src/sharing/helpers.ts b/packages/arcgis-rest-portal/src/sharing/helpers.ts index 77b7f45f97..24d5805c9d 100644 --- a/packages/arcgis-rest-portal/src/sharing/helpers.ts +++ b/packages/arcgis-rest-portal/src/sharing/helpers.ts @@ -9,6 +9,7 @@ import { } from "@esri/arcgis-rest-request"; import { getPortalUrl } from "../util/get-portal-url.js"; import { getGroup } from "../groups/get.js"; +import { getSelf } from "../util/get-portal.js"; export interface ISharingOptions extends IUserRequestOptions { /** @@ -27,18 +28,26 @@ export interface ISharingResponse { itemId: string; } -export function getSharingUrl(requestOptions: ISharingOptions): string { - const username = requestOptions.authentication.username; - const owner = requestOptions.owner || username; +export function getSharingUrl( + requestOptions: ISharingOptions, + username?: string +): string { + const providedUsername = + username || (requestOptions.authentication as any).username; // as any workaround for backward compatibility for discovering username from provided auth method + const owner = requestOptions.owner || providedUsername; return `${getPortalUrl(requestOptions)}/content/users/${encodeURIComponent( owner )}/items/${requestOptions.id}/share`; } -export function isItemOwner(requestOptions: ISharingOptions): boolean { - const username = requestOptions.authentication.username; - const owner = requestOptions.owner || username; - return owner === username; +export function isItemOwner( + requestOptions: ISharingOptions, + username?: string +): boolean { + const providedUsername = + username || (requestOptions.authentication as any).username; // as any workaround for backward compatibility for discovering username from provided auth method + const owner = requestOptions.owner || providedUsername; + return owner === providedUsername; } /** @@ -49,9 +58,7 @@ export function isItemOwner(requestOptions: ISharingOptions): boolean { export function isOrgAdmin( requestOptions: IUserRequestOptions ): Promise { - const session = requestOptions.authentication; - - return session.getUser(requestOptions).then((user: IUser) => { + return requestOptions.authentication.getUser().then((user: IUser) => { return user && user.role === "org_admin" && !user.roleId; }); } diff --git a/packages/arcgis-rest-portal/src/sharing/share-item-with-group.ts b/packages/arcgis-rest-portal/src/sharing/share-item-with-group.ts index b620146bcd..fe10515451 100644 --- a/packages/arcgis-rest-portal/src/sharing/share-item-with-group.ts +++ b/packages/arcgis-rest-portal/src/sharing/share-item-with-group.ts @@ -38,11 +38,11 @@ interface IEnsureMembershipResult { * @param requestOptions - Options for the request. * @returns A Promise that will resolve with the data from the response. */ -export function shareItemWithGroup( +export async function shareItemWithGroup( requestOptions: IGroupSharingOptions ): Promise { return isItemSharedWithGroup(requestOptions) - .then((isShared) => { + .then(async (isShared) => { if (isShared) { // already shared, exit early with success response return { @@ -52,11 +52,8 @@ export function shareItemWithGroup( } as ISharingResponse; } - const { - authentication: { username }, - owner, - confirmItemControl - } = requestOptions; + const { owner, confirmItemControl } = requestOptions; + const username = await requestOptions.authentication.getUsername(); const itemOwner = owner || username; // non-item owner @@ -193,12 +190,12 @@ function getMembershipAdjustments( return membershipGuarantees; } -function shareToGroup( +async function shareToGroup( requestOptions: IGroupSharingOptions, isAdmin = false, isCrossOrgSharing = false ): Promise { - const username = requestOptions.authentication.username; + const username = await requestOptions.authentication.getUsername(); const itemOwner = requestOptions.owner || username; // decide what url to use // default to the non-owner url... diff --git a/packages/arcgis-rest-portal/src/sharing/unshare-item-with-group.ts b/packages/arcgis-rest-portal/src/sharing/unshare-item-with-group.ts index 4569b29a93..800c91cbe9 100644 --- a/packages/arcgis-rest-portal/src/sharing/unshare-item-with-group.ts +++ b/packages/arcgis-rest-portal/src/sharing/unshare-item-with-group.ts @@ -31,7 +31,7 @@ import { getUser } from "../users/get-user.js"; export function unshareItemWithGroup( requestOptions: IGroupSharingOptions ): Promise { - return isItemSharedWithGroup(requestOptions).then((isShared) => { + return isItemSharedWithGroup(requestOptions).then(async (isShared) => { // not shared if (!isShared) { // exit early with success response @@ -42,11 +42,8 @@ export function unshareItemWithGroup( } as ISharingResponse); } - const { - authentication: { username }, - owner - } = requestOptions; - + const { owner } = requestOptions; + const username = await requestOptions.authentication.getUsername(); // next check if the user is a member of the group return Promise.all([ getUserMembership(requestOptions), @@ -87,10 +84,10 @@ export function unshareItemWithGroup( }); } -function unshareFromGroup( +async function unshareFromGroup( requestOptions: IGroupSharingOptions ): Promise { - const username = requestOptions.authentication.username; + const username = await requestOptions.authentication.getUsername(); const itemOwner = requestOptions.owner || username; // decide what url to use // default to the non-owner url... diff --git a/packages/arcgis-rest-portal/src/users/get-user-tags.ts b/packages/arcgis-rest-portal/src/users/get-user-tags.ts index 2a1b06377d..bb48cfe322 100644 --- a/packages/arcgis-rest-portal/src/users/get-user-tags.ts +++ b/packages/arcgis-rest-portal/src/users/get-user-tags.ts @@ -4,6 +4,7 @@ import { request } from "@esri/arcgis-rest-request"; import { getPortalUrl } from "../util/get-portal-url.js"; import { IGetUserOptions } from "./get-user.js"; +import { determineUsername } from "../util/determine-username.js"; export interface ITagCount { /** @@ -39,14 +40,13 @@ export interface IGetUserTagsResponse { * @param IGetUserOptions - options to pass through in the request * @returns A Promise that will resolve with the user tag array */ -export function getUserTags( +export async function getUserTags( requestOptions: IGetUserOptions ): Promise { - const username = - requestOptions.username || requestOptions.authentication.username; + const username = await determineUsername(requestOptions); const url = `${getPortalUrl( requestOptions - )}/community/users/${encodeURIComponent(username)}/tags`; + )}/community/users/${username}/tags`; // send the request return request(url, requestOptions); diff --git a/packages/arcgis-rest-portal/src/users/get-user-url.ts b/packages/arcgis-rest-portal/src/users/get-user-url.ts index 9f3cadb0ad..d76be92da6 100644 --- a/packages/arcgis-rest-portal/src/users/get-user-url.ts +++ b/packages/arcgis-rest-portal/src/users/get-user-url.ts @@ -10,6 +10,7 @@ import { getPortalUrl } from "../util/get-portal-url.js"; * * @param session * @returns User url to be used in API requests. + * @deprecated This function requires a synchronous `username` on the session object which is not guaranteed. Use `getUser` instead. This function will be removed in the next release. */ export function getUserUrl(session: ArcGISIdentityManager): string { return `${getPortalUrl(session)}/community/users/${encodeURIComponent( diff --git a/packages/arcgis-rest-portal/src/users/get-user.ts b/packages/arcgis-rest-portal/src/users/get-user.ts index a003f5403b..2a23d7cc19 100644 --- a/packages/arcgis-rest-portal/src/users/get-user.ts +++ b/packages/arcgis-rest-portal/src/users/get-user.ts @@ -5,16 +5,18 @@ import { request, IRequestOptions, ArcGISIdentityManager, - IUser + IUser, + IAuthenticationManager } from "@esri/arcgis-rest-request"; import { getPortalUrl } from "../util/get-portal-url.js"; +import { determineUsername } from "../util/determine-username.js"; export interface IGetUserOptions extends IRequestOptions { /** * A session representing a logged in user. */ - authentication?: ArcGISIdentityManager; + authentication?: IAuthenticationManager; /** * Supply a username if you'd like to fetch information about a different user than is being used to authenticate the request. */ @@ -35,7 +37,7 @@ export interface IGetUserOptions extends IRequestOptions { * @param requestOptions - options to pass through in the request * @returns A Promise that will resolve with metadata about the user */ -export function getUser( +export async function getUser( requestOptions?: string | IGetUserOptions ): Promise { let url; @@ -46,11 +48,8 @@ export function getUser( url = `https://www.arcgis.com/sharing/rest/community/users/${requestOptions}`; } else { // if an authenticated session is passed, default to that user/portal unless another username is provided manually - const username = - requestOptions.username || requestOptions.authentication.username; - url = `${getPortalUrl(requestOptions)}/community/users/${encodeURIComponent( - username - )}`; + const username = await determineUsername(requestOptions); + url = `${getPortalUrl(requestOptions)}/community/users/${username}`; options = { ...requestOptions, ...options diff --git a/packages/arcgis-rest-portal/src/users/invitation.ts b/packages/arcgis-rest-portal/src/users/invitation.ts index 5db414e289..83542c2544 100644 --- a/packages/arcgis-rest-portal/src/users/invitation.ts +++ b/packages/arcgis-rest-portal/src/users/invitation.ts @@ -8,6 +8,7 @@ import { } from "@esri/arcgis-rest-request"; import { getPortalUrl } from "../util/get-portal-url.js"; +import { determineUsername } from "../util/determine-username.js"; export interface IInvitation { id: string; @@ -48,12 +49,11 @@ export interface IInvitationResult { * @param requestOptions - options to pass through in the request * @returns A Promise that will resolve with the user's invitations */ -export function getUserInvitations( +export async function getUserInvitations( requestOptions: IUserRequestOptions ): Promise { let options = { httpMethod: "GET" } as IUserRequestOptions; - - const username = encodeURIComponent(requestOptions.authentication.username); + const username = await determineUsername(requestOptions); const portalUrl = getPortalUrl(requestOptions); const url = `${portalUrl}/community/users/${username}/invitations`; options = { ...requestOptions, ...options }; @@ -82,10 +82,10 @@ export interface IGetUserInvitationOptions extends IUserRequestOptions { * @param requestOptions - options to pass through in the request * @returns A Promise that will resolve with the invitation */ -export function getUserInvitation( +export async function getUserInvitation( requestOptions: IGetUserInvitationOptions ): Promise { - const username = encodeURIComponent(requestOptions.authentication.username); + const username = await determineUsername(requestOptions); const portalUrl = getPortalUrl(requestOptions); const url = `${portalUrl}/community/users/${username}/invitations/${requestOptions.invitationId}`; @@ -112,7 +112,7 @@ export function getUserInvitation( * @param requestOptions - Options for the request * @returns A Promise that will resolve with the success/failure status of the request */ -export function acceptInvitation( +export async function acceptInvitation( requestOptions: IGetUserInvitationOptions ): Promise<{ success: boolean; @@ -120,7 +120,7 @@ export function acceptInvitation( groupId: string; id: string; }> { - const username = encodeURIComponent(requestOptions.authentication.username); + const username = await determineUsername(requestOptions); const portalUrl = getPortalUrl(requestOptions); const url = `${portalUrl}/community/users/${username}/invitations/${requestOptions.invitationId}/accept`; @@ -144,7 +144,7 @@ export function acceptInvitation( * @param requestOptions - Options for the request * @returns A Promise that will resolve with the success/failure status of the request */ -export function declineInvitation( +export async function declineInvitation( requestOptions: IGetUserInvitationOptions ): Promise<{ success: boolean; @@ -152,7 +152,7 @@ export function declineInvitation( groupId: string; id: string; }> { - const username = encodeURIComponent(requestOptions.authentication.username); + const username = await determineUsername(requestOptions); const portalUrl = getPortalUrl(requestOptions); const url = `${portalUrl}/community/users/${username}/invitations/${requestOptions.invitationId}/decline`; diff --git a/packages/arcgis-rest-portal/src/users/notification.ts b/packages/arcgis-rest-portal/src/users/notification.ts index 67039347b3..34d6888030 100644 --- a/packages/arcgis-rest-portal/src/users/notification.ts +++ b/packages/arcgis-rest-portal/src/users/notification.ts @@ -4,6 +4,7 @@ import { request, IUserRequestOptions } from "@esri/arcgis-rest-request"; import { getPortalUrl } from "../util/get-portal-url.js"; +import { determineUsername } from "../util/determine-username.js"; export interface INotification { id: string; @@ -38,12 +39,12 @@ export interface INotificationResult { * @param requestOptions - options to pass through in the request * @returns A Promise that will resolve with the user's notifications */ -export function getUserNotifications( +export async function getUserNotifications( requestOptions: IUserRequestOptions ): Promise { let options = { httpMethod: "GET" } as IUserRequestOptions; - const username = encodeURIComponent(requestOptions.authentication.username); + const username = await determineUsername(requestOptions); const portalUrl = getPortalUrl(requestOptions); const url = `${portalUrl}/community/users/${username}/notifications`; options = { ...requestOptions, ...options }; @@ -58,10 +59,10 @@ export function getUserNotifications( * @param requestOptions - Options for the request * @returns A Promise that will resolve with the success/failure status of the request */ -export function removeNotification( +export async function removeNotification( requestOptions: IRemoveNotificationOptions ): Promise<{ success: boolean; notificationId: string }> { - const username = encodeURIComponent(requestOptions.authentication.username); + const username = await determineUsername(requestOptions); const portalUrl = getPortalUrl(requestOptions); const url = `${portalUrl}/community/users/${username}/notifications/${requestOptions.id}/delete`; diff --git a/packages/arcgis-rest-portal/src/users/update.ts b/packages/arcgis-rest-portal/src/users/update.ts index 476f943c57..5d50c5f92d 100644 --- a/packages/arcgis-rest-portal/src/users/update.ts +++ b/packages/arcgis-rest-portal/src/users/update.ts @@ -42,12 +42,13 @@ export interface IUpdateUserResponse { * @param requestOptions - options to pass through in the request * @returns A Promise that will resolve with metadata about the user */ -export function updateUser( +export async function updateUser( requestOptions?: IUpdateUserOptions ): Promise { // default to the authenticated username unless another username is provided manually const username = - requestOptions.user.username || requestOptions.authentication.username; + requestOptions.user.username || + (await requestOptions.authentication.getUsername()); const updateUrl = `${getPortalUrl( requestOptions diff --git a/packages/arcgis-rest-portal/src/util/determine-username.ts b/packages/arcgis-rest-portal/src/util/determine-username.ts new file mode 100644 index 0000000000..f11e9ca0e5 --- /dev/null +++ b/packages/arcgis-rest-portal/src/util/determine-username.ts @@ -0,0 +1,35 @@ +/* Copyright (c) 2018 Environmental Systems Research Institute, Inc. + * Apache-2.0 */ + +import { + IAuthenticationManager, + IRequestOptions +} from "@esri/arcgis-rest-request"; + +interface RequestOptionsWithUsername extends Partial { + username?: string; + authentication?: IAuthenticationManager; +} + +/** + * Used to determine the username to use in a request. Will use the `username` passed in the + * `requestOptions` if present, otherwise will use the username from the `authentication` option. + * This method is used internally to determine the username to use in a request and is async to + * support the case where the username is not immediately available. + * + * @param requestOptions the requests options + * @returns the authentecated users username encoded for use in a URL. + */ +export async function determineUsername( + requestOptions: RequestOptionsWithUsername +): Promise { + if (requestOptions.username) { + return encodeURIComponent(requestOptions.username); + } + if ((requestOptions.authentication as any)?.username) { + return encodeURIComponent((requestOptions.authentication as any).username); + } + if (requestOptions.authentication) { + return requestOptions.authentication.getUsername().then(encodeURIComponent); + } +} diff --git a/packages/arcgis-rest-portal/test/sharing/helpers.test.ts b/packages/arcgis-rest-portal/test/sharing/helpers.test.ts index 6017b418ee..b082aee0f2 100644 --- a/packages/arcgis-rest-portal/test/sharing/helpers.test.ts +++ b/packages/arcgis-rest-portal/test/sharing/helpers.test.ts @@ -2,12 +2,13 @@ * Apache-2.0 */ import fetchMock from "fetch-mock"; -import { getUserMembership } from "../../src/sharing/helpers.js"; +import { getSharingUrl, getUserMembership } from "../../src/sharing/helpers.js"; import { MOCK_USER_SESSION } from "../mocks/sharing/sharing.js"; import { GroupOwnerResponse, GroupNoAccessResponse } from "./share-item-with-group.test.js"; +import { isItemOwner } from "../../src/sharing/helpers.js"; describe("sharing helpers ::", () => { afterEach(() => { @@ -53,5 +54,39 @@ describe("sharing helpers ::", () => { fail(e); }); }); + + describe("isItemOwner ::", () => { + it("should use the username from the session if none is passed", () => { + expect( + isItemOwner({ + id: "3ef", + owner: "casey", + authentication: MOCK_USER_SESSION + }) + ).toBe(false); + + expect( + isItemOwner({ + id: "3ef", + owner: "jsmith", + authentication: MOCK_USER_SESSION + }) + ).toBe(true); + }); + }); + + describe("getSharingUrl ::", () => { + it("should use the username from the session if none is passed", () => { + expect( + getSharingUrl({ + id: "3ef", + owner: "casey", + authentication: MOCK_USER_SESSION + }) + ).toBe( + "https://myorg.maps.arcgis.com/sharing/rest/content/users/casey/items/3ef/share" + ); + }); + }); }); }); diff --git a/packages/arcgis-rest-portal/test/util/determine-username.test.ts b/packages/arcgis-rest-portal/test/util/determine-username.test.ts new file mode 100644 index 0000000000..a420441c7c --- /dev/null +++ b/packages/arcgis-rest-portal/test/util/determine-username.test.ts @@ -0,0 +1,68 @@ +/* Copyright (c) 2018 Environmental Systems Research Institute, Inc. + * Apache-2.0 */ + +import { getUser } from "../../src/index.js"; +import { determineUsername } from "../../src/util/determine-username.js"; + +describe("determineUsername", () => { + it("should return undefined if no username is available", () => { + const requestOptions = {}; + return determineUsername(requestOptions).then((username) => { + expect(username).toEqual(undefined); + }); + }); + + it("should use the username in the requestOptions if passed", () => { + const requestOptions = { + username: "c@sey", + authentication: { + portal: "https://bar.com/arcgis/sharing/rest", + username: "bob", + getUsername: function () { + return Promise.resolve("jsmith"); + }, + getToken() { + return Promise.resolve("fake"); + } + } + }; + return determineUsername(requestOptions).then((username) => { + expect(username).toEqual(encodeURIComponent("c@sey")); + }); + }); + + it("should fallback to the username in the requestOptions authentication", () => { + const requestOptions = { + authentication: { + portal: "https://bar.com/arcgis/sharing/rest", + username: "bob", + getUsername: function () { + return Promise.resolve("jsmith"); + }, + getToken() { + return Promise.resolve("fake"); + } + } + }; + return determineUsername(requestOptions).then((username) => { + expect(username).toEqual(encodeURIComponent("bob")); + }); + }); + + it("should fallback to getUsername() in the requestOptions authentication", () => { + const requestOptions = { + authentication: { + portal: "https://bar.com/arcgis/sharing/rest", + getUsername: function () { + return Promise.resolve("jsmith"); + }, + getToken() { + return Promise.resolve("fake"); + } + } + }; + return determineUsername(requestOptions).then((username) => { + expect(username).toEqual(encodeURIComponent("jsmith")); + }); + }); +}); diff --git a/packages/arcgis-rest-request/src/ApiKeyManager.ts b/packages/arcgis-rest-request/src/ApiKeyManager.ts index 9f92efb7a4..fb1cb9317d 100644 --- a/packages/arcgis-rest-request/src/ApiKeyManager.ts +++ b/packages/arcgis-rest-request/src/ApiKeyManager.ts @@ -2,12 +2,15 @@ * Apache-2.0 */ import { IAuthenticationManager } from "./utils/IAuthenticationManager.js"; +import { AuthenticationManagerBase } from "./AuthenticationManagerBase.js"; /** * Options for the `ApiKey` constructor. */ export interface IApiKeyOptions { key: string; + username?: string; + portal?: string; } /** @@ -21,7 +24,10 @@ export interface IApiKeyOptions { * * In most cases however the API key can be passed directly to the {@linkcode IRequestOptions.authentication}. */ -export class ApiKeyManager implements IAuthenticationManager { +export class ApiKeyManager + extends AuthenticationManagerBase + implements IAuthenticationManager +{ /** * The current portal the user is authenticated with. */ @@ -32,11 +38,16 @@ export class ApiKeyManager implements IAuthenticationManager { /** * The preferred method for creating an instance of `ApiKeyManager`. */ - public static fromKey(apiKey: string) { - return new ApiKeyManager({ key: apiKey }); + public static fromKey(apiKey: string | IApiKeyOptions) { + if (typeof apiKey === "string") { + return new ApiKeyManager({ key: apiKey }); + } else { + return new ApiKeyManager(apiKey); + } } constructor(options: IApiKeyOptions) { + super(options); this.key = options.key; } diff --git a/packages/arcgis-rest-request/src/ApplicationCredentialsManager.ts b/packages/arcgis-rest-request/src/ApplicationCredentialsManager.ts index ee72a81b67..d66be5507e 100644 --- a/packages/arcgis-rest-request/src/ApplicationCredentialsManager.ts +++ b/packages/arcgis-rest-request/src/ApplicationCredentialsManager.ts @@ -9,6 +9,7 @@ import { ArcGISTokenRequestErrorCodes } from "./utils/ArcGISTokenRequestError.js"; import { ArcGISRequestError } from "./utils/ArcGISRequestError.js"; +import { AuthenticationManagerBase } from "./AuthenticationManagerBase.js"; export interface IApplicationCredentialsManagerOptions { /** @@ -58,7 +59,10 @@ export interface IApplicationCredentialsManagerOptions { * }) * ``` */ -export class ApplicationCredentialsManager implements IAuthenticationManager { +export class ApplicationCredentialsManager + extends AuthenticationManagerBase + implements IAuthenticationManager +{ public portal: string; private clientId: string; private clientSecret: string; @@ -82,6 +86,7 @@ export class ApplicationCredentialsManager implements IAuthenticationManager { private _pendingTokenRequest: Promise; constructor(options: IApplicationCredentialsManagerOptions) { + super(options); this.clientId = options.clientId; this.clientSecret = options.clientSecret; this.token = options.token; @@ -138,6 +143,7 @@ export class ApplicationCredentialsManager implements IAuthenticationManager { } public refreshCredentials() { + this.clearCachedUserInfo(); return this.refreshToken().then(() => this); } } diff --git a/packages/arcgis-rest-request/src/ArcGISIdentityManager.ts b/packages/arcgis-rest-request/src/ArcGISIdentityManager.ts index c94f1648c6..14f05f72cc 100644 --- a/packages/arcgis-rest-request/src/ArcGISIdentityManager.ts +++ b/packages/arcgis-rest-request/src/ArcGISIdentityManager.ts @@ -22,6 +22,7 @@ import { ArcGISTokenRequestErrorCodes } from "./utils/ArcGISTokenRequestError.js"; import { NODEJS_DEFAULT_REFERER_HEADER } from "./index.js"; +import { AuthenticationManagerBase } from "./AuthenticationManagerBase.js"; /** * Options for {@linkcode ArcGISIdentityManager.fromToken}. @@ -280,7 +281,10 @@ export interface IArcGISIdentityManagerOptions { * * {@linkcode ArcGISIdentityManager.deserialize} will create a new `ArcGISIdentityManager` from a JSON object created with {@linkcode ArcGISIdentityManager.serialize} * * {@linkcode ArcGISIdentityManager.destroy} or {@linkcode ArcGISIdentityManager.signOut} will invalidate any tokens in use by the `ArcGISIdentityManager`. */ -export class ArcGISIdentityManager implements IAuthenticationManager { +export class ArcGISIdentityManager + extends AuthenticationManagerBase + implements IAuthenticationManager +{ /** * The current ArcGIS Online or ArcGIS Enterprise `token`. */ @@ -309,19 +313,6 @@ export class ArcGISIdentityManager implements IAuthenticationManager { return this._refreshTokenExpires; } - /** - * The currently authenticated user. - */ - get username() { - if (this._username) { - return this._username; - } - - if (this._user && this._user.username) { - return this._user.username; - } - } - /** * Returns `true` if these credentials can be refreshed and `false` if it cannot. */ @@ -1014,11 +1005,6 @@ export class ArcGISIdentityManager implements IAuthenticationManager { */ public readonly referer: string; - /** - * Hydrated by a call to [getUser()](#getUser-summary). - */ - private _user: IUser; - /** * Hydrated by a call to [getPortal()](#getPortal-summary). */ @@ -1028,7 +1014,6 @@ export class ArcGISIdentityManager implements IAuthenticationManager { private _tokenExpires: Date; private _refreshToken: string; private _refreshTokenExpires: Date; - private _pendingUserRequest: Promise; private _pendingPortalRequest: Promise; /** @@ -1039,8 +1024,6 @@ export class ArcGISIdentityManager implements IAuthenticationManager { [key: string]: Promise; }; - private _username: string; - /** * Internal list of tokens to 3rd party servers (federated servers) that have * been created via `generateToken`. The object key is the root URL of the server. @@ -1061,10 +1044,10 @@ export class ArcGISIdentityManager implements IAuthenticationManager { private _hostHandler: any; constructor(options: IArcGISIdentityManagerOptions) { + super(options); this.clientId = options.clientId; this._refreshToken = options.refreshToken; this._refreshTokenExpires = options.refreshTokenExpires; - this._username = options.username; this.password = options.password; this._token = options.token; this._tokenExpires = options.tokenExpires; @@ -1118,44 +1101,6 @@ export class ArcGISIdentityManager implements IAuthenticationManager { }; } - /** - * Returns information about the currently logged in [user](https://developers.arcgis.com/rest/users-groups-and-items/user.htm). Subsequent calls will *not* result in additional web traffic. - * - * ```js - * manager.getUser() - * .then(response => { - * console.log(response.role); // "org_admin" - * }) - * ``` - * - * @param requestOptions - Options for the request. NOTE: `rawResponse` is not supported by this operation. - * @returns A Promise that will resolve with the data from the response. - */ - public getUser(requestOptions?: IRequestOptions): Promise { - if (this._pendingUserRequest) { - return this._pendingUserRequest; - } else if (this._user) { - return Promise.resolve(this._user); - } else { - const url = `${this.portal}/community/self`; - - const options = { - httpMethod: "GET", - authentication: this, - ...requestOptions, - rawResponse: false - } as IRequestOptions; - - this._pendingUserRequest = request(url, options).then((response) => { - this._user = response; - this._pendingUserRequest = null; - return response; - }); - - return this._pendingUserRequest; - } - } - /** * Returns information about the currently logged in user's [portal](https://developers.arcgis.com/rest/users-groups-and-items/portal-self.htm). Subsequent calls will *not* result in additional web traffic. * @@ -1194,26 +1139,6 @@ export class ArcGISIdentityManager implements IAuthenticationManager { } } - /** - * Returns the username for the currently logged in [user](https://developers.arcgis.com/rest/users-groups-and-items/user.htm). Subsequent calls will *not* result in additional web traffic. This is also used internally when a username is required for some requests but is not present in the options. - * - * ```js - * manager.getUsername() - * .then(response => { - * console.log(response); // "casey_jones" - * }) - * ``` - */ - public getUsername() { - if (this.username) { - return Promise.resolve(this.username); - } else { - return this.getUser().then((user) => { - return user.username; - }); - } - } - /** * Gets an appropriate token for the given URL. If `portal` is ArcGIS Online and * the request is to an ArcGIS Online domain `token` will be used. If the request @@ -1299,7 +1224,7 @@ export class ArcGISIdentityManager implements IAuthenticationManager { */ public refreshCredentials(requestOptions?: ITokenRequestOptions) { // make sure subsequent calls to getUser() don't returned cached metadata - this._user = null; + this.clearCachedUserInfo(); if (this.username && this.password) { return this.refreshWithUsernameAndPassword(requestOptions); diff --git a/packages/arcgis-rest-request/src/AuthenticationManagerBase.ts b/packages/arcgis-rest-request/src/AuthenticationManagerBase.ts new file mode 100644 index 0000000000..55831a2aea --- /dev/null +++ b/packages/arcgis-rest-request/src/AuthenticationManagerBase.ts @@ -0,0 +1,113 @@ +import { IUser } from "./types/user.js"; +import { IRequestOptions } from "./utils/IRequestOptions.js"; +import { request } from "./request.js"; +import { cleanUrl } from "./utils/clean-url.js"; + +class AuthenticationManagerBase { + /** + * The current portal the user is authenticated with. + */ + public readonly portal: string; + + /** + * The username of the currently authenticated user. + */ + get username() { + if (this._username) { + return this._username; + } + + if (this._user && this._user.username) { + return this._user.username; + } + } + + constructor(options: any) { + this.portal = options.portal + ? cleanUrl(options.portal) + : "https://www.arcgis.com/sharing/rest"; + this._username = options.username; + } + + /** + * Internal varible to track the pending user request so we do not make multiple requests. + */ + private _pendingUserRequest: Promise; + + /** + * Hydrated by a call to [getUser()](#getUser-summary). + */ + private _user: IUser; + + /** + * Internal variable to store the username. + */ + private _username: string; + + /** + * Returns the username for the currently logged in [user](https://developers.arcgis.com/rest/users-groups-and-items/user.htm). Subsequent calls will *not* result in additional web traffic. This is also used internally when a username is required for some requests but is not present in the options. + * + * ```js + * manager.getUsername() + * .then(response => { + * console.log(response); // "casey_jones" + * }) + * ``` + */ + public getUsername() { + if (this.username) { + return Promise.resolve(this.username); + } else { + return this.getUser().then((user) => { + return user.username; + }); + } + } + + /** + * Returns information about the currently logged in [user](https://developers.arcgis.com/rest/users-groups-and-items/user.htm). Subsequent calls will *not* result in additional web traffic. + * + * ```js + * manager.getUser() + * .then(response => { + * console.log(response.role); // "org_admin" + * }) + * ``` + * + * @param requestOptions - Options for the request. NOTE: `rawResponse` is not supported by this operation. + * @returns A Promise that will resolve with the data from the response. + */ + public getUser(requestOptions?: IRequestOptions): Promise { + if (this._pendingUserRequest) { + return this._pendingUserRequest; + } else if (this._user) { + return Promise.resolve(this._user); + } else { + const url = `${this.portal}/community/self`; + + const options = { + httpMethod: "GET", + authentication: this, + ...requestOptions, + rawResponse: false + } as IRequestOptions; + + this._pendingUserRequest = request(url, options).then((response) => { + this._user = response; + this._pendingUserRequest = null; + return response; + }); + + return this._pendingUserRequest; + } + } + + /** + * Clear the cached user infornation. Usefull to ensure that the most recent user information from {@linkcode AuthenticationManagerBase.getUser} is used. + */ + public clearCachedUserInfo() { + this._user = null; + } +} + +export { AuthenticationManagerBase }; diff --git a/packages/arcgis-rest-request/src/authenticated-request-options.ts b/packages/arcgis-rest-request/src/authenticated-request-options.ts index 2747e1d8ba..5737a62ec9 100644 --- a/packages/arcgis-rest-request/src/authenticated-request-options.ts +++ b/packages/arcgis-rest-request/src/authenticated-request-options.ts @@ -3,13 +3,14 @@ import { ApplicationCredentialsManager } from "./ApplicationCredentialsManager.js"; import { ArcGISIdentityManager } from "./ArcGISIdentityManager.js"; +import { IAuthenticationManager } from "./utils/IAuthenticationManager.js"; import { IRequestOptions } from "./utils/IRequestOptions.js"; /** * Used internally by packages for requests that require user authentication. */ export interface IAuthenticatedRequestOptions extends IRequestOptions { - authentication: ArcGISIdentityManager | ApplicationCredentialsManager; + authentication: IAuthenticationManager; } /** @@ -19,5 +20,5 @@ export interface IUserRequestOptions extends IRequestOptions { /** * A session representing a logged in user. */ - authentication: ArcGISIdentityManager; + authentication: IAuthenticationManager; } diff --git a/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts b/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts index b7b7ff007c..b4164e885b 100644 --- a/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts +++ b/packages/arcgis-rest-request/src/utils/IAuthenticationManager.ts @@ -1,3 +1,4 @@ +import { IUser } from "../types/user.js"; import { ITokenRequestOptions } from "./ITokenRequestOptions.js"; /** * Authentication can be supplied to `request` via {@linkcode ArcGISIdentityManager}, {@linkcode ApplicationCredentialsManager} or {@linkcode APIKeyManager}. These classes implement {@linkCode IAuthenticationManager}. @@ -26,6 +27,16 @@ export interface IAuthenticationManager { */ getToken(url: string, requestOptions?: ITokenRequestOptions): Promise; + /** + * Optional. Returns a promise that resolves with the username. Used internally by some methods to consruct URLs that require a username. + */ + getUsername?(): Promise; + + /** + * Optional. Returns a promise that resolves with the user. Used internally by some methods to check if the user is an admin but can be used as a generic way to get user information. + */ + getUser?(): Promise; + /** * Optional. Returns the proper [`credentials` option for `fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) for a given domain. * diff --git a/packages/arcgis-rest-request/test/ApiKey.test.ts b/packages/arcgis-rest-request/test/ApiKey.test.ts index 7729839db3..6e79dfd6d1 100644 --- a/packages/arcgis-rest-request/test/ApiKey.test.ts +++ b/packages/arcgis-rest-request/test/ApiKey.test.ts @@ -32,7 +32,7 @@ describe("ApiKeyManager", () => { }); describe(".fromKey()", () => { - it("should create a new ApiKeyManager", (done) => { + it("should create a new ApiKeyManager from a string", (done) => { const session = ApiKeyManager.fromKey("123456"); Promise.all([ @@ -50,5 +50,87 @@ describe("ApiKeyManager", () => { fail(e); }); }); + + it("should create a new ApiKeyManager from an options object", (done) => { + const session = ApiKeyManager.fromKey({ + key: "123456", + username: "c@sey" + }); + + expect(session.username).toBe("c@sey"); + + Promise.all([ + session.getToken("https://www.arcgis.com/sharing/rest/portals/self"), + session.getToken( + "https://services1.arcgis.com/MOCK_ORG/arcgis/rest/services/Private_Service/FeatureServer" + ) + ]) + .then(([token1, token2]) => { + expect(token1).toBe("123456"); + expect(token2).toBe("123456"); + done(); + }) + .catch((e) => { + fail(e); + }); + }); + }); + + describe(".getUsername()", () => { + afterEach(() => { + fetchMock.restore(); + }); + + it("should fetch the username via getUser()", (done) => { + // we intentionally only mock one response + fetchMock.once( + "https://www.arcgis.com/sharing/rest/community/self?f=json&token=token", + { + username: "jsmith" + } + ); + + const session = ApiKeyManager.fromKey({ + key: "token" + }); + + session + .getUsername() + .then((response) => { + expect(response).toEqual("jsmith"); + + // also test getting it from the cache. + session + .getUsername() + .then((username) => { + done(); + + expect(username).toEqual("jsmith"); + }) + .catch((e) => { + fail(e); + }); + }) + .catch((e) => { + fail(e); + }); + }); + + it("should use a username if passed in the session", (done) => { + const session = ApiKeyManager.fromKey({ + key: "token", + username: "jsmith" + }); + + session + .getUsername() + .then((response) => { + expect(response).toEqual("jsmith"); + done(); + }) + .catch((e) => { + fail(e); + }); + }); }); });