Skip to content

Add invalidateApiKey(), generate API key bug fix, doc updates #1196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/arcgis-rest-developer-credentials/src/createApiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,20 @@ import { getRegisteredAppInfo } from "./shared/getRegisteredAppInfo.js";
* password: "xyz_pw"
* });
*
* const threeDaysFromToday = new Date();
* threeDaysFromToday.setDate(threeDaysFromToday.getDate() + 3);
* threeDaysFromToday.setHours(23, 59, 59, 999);
*
* createApiKey({
* title: "xyz_title",
* description: "xyz_desc",
* tags: ["xyz_tag1", "xyz_tag2"],
* privileges: ["premium:user:networkanalysis:routing"],
* authentication: authSession
* authentication: authSession,
* generateToken1: true, // optional,generate a new token
* apiToken1ExpirationDate: threeDaysFromToday // optional, update expiration date
* }).then((registeredAPIKey: IApiKeyResponse) => {
* // => {apiKey: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
* // => {accessToken1: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
* }).catch(e => {
* // => an exception object
* });
Expand Down
1 change: 1 addition & 0 deletions packages/arcgis-rest-developer-credentials/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export * from "./createApiKey.js";
export * from "./updateApiKey.js";
export * from "./getApiKey.js";
export * from "./invalidateApiKey.js";
export * from "./getOAuthApp.js";
export * from "./updateOAuthApp.js";
export * from "./createOAuthApp.js";
Expand Down
52 changes: 52 additions & 0 deletions packages/arcgis-rest-developer-credentials/src/invalidateApiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* Copyright (c) 2023 Environmental Systems Research Institute, Inc.
* Apache-2.0 */

import {
IInvalidateApiKeyOptions,
IInvalidateApiKeyResponse
} from "./shared/types/apiKeyType.js";
import { getRegisteredAppInfo } from "./shared/getRegisteredAppInfo.js";
import { getPortalUrl } from "@esri/arcgis-rest-portal";
import { request } from "@esri/arcgis-rest-request";
import { slotForInvalidationKey } from "./shared/helpers.js";

/**
* Used to invalidate an API key.
*
* ```js
* import { invalidateApiKey } from "@esri/arcgis-rest-developer-credentials";
*
* invalidateApiKey({
* itemId: ITEM_ID,
* authentication,
* apiKey: 1, // invalidate the key in slot 1
* }).then((response) => {
* // => {success: true}
* }).catch(e => {
* // => an exception object
* });
*/
export async function invalidateApiKey(
requestOptions: IInvalidateApiKeyOptions
): Promise<IInvalidateApiKeyResponse> {
const portal = getPortalUrl(requestOptions);
const url = `${portal}/oauth2/revokeToken`;

const appInfo = await getRegisteredAppInfo({
itemId: requestOptions.itemId,
authentication: requestOptions.authentication
});

const params = {
client_id: appInfo.client_id,
client_secret: appInfo.client_secret,
apiToken: slotForInvalidationKey(requestOptions.apiKey),
regenerateApiToken: true,
grant_type: "client_credentials"
};

// authentication is not being passed to the request because client_secret acts as the auth
return request(url, {
params
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export async function generateApiKeyToken(
grant_type: "client_credentials"
};

// authentication is not being passed to the request because client_secret acts as the auth
return request(url, {
authentication: options.authentication,
params
});
}
29 changes: 27 additions & 2 deletions packages/arcgis-rest-developer-credentials/src/shared/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,35 @@ export function filterKeys<T extends object>(
}

/**
* Used to determine if a generated key is in slot 1 or slot 2 key.
* Used to determine if a generated key is in slot 1 or slot 2 key. The full API key should be passed. `undefined` will be returned if the proper slot could not be identified.
*/
export function slotForKey(key: string) {
return parseInt(key.substring(key.length - 10, key.length - 9));
const slot = parseInt(key.substring(key.length - 10, key.length - 9));

if (slot === 1 || slot === 2) {
return slot;
}

return undefined;
}

/**
* @internal
* Used to determine which slot to invalidate a key in given a number or a full or patial key.
*/
export function slotForInvalidationKey(param: string | 1 | 2) {
if (param === 1 || param === 2) {
return param;
}

if (typeof param !== "string") {
return undefined;
}

const fullKeySlot = slotForKey(param);
if (fullKeySlot) {
return fullKeySlot;
}
}

interface IGenerateApiKeyTokenOptions extends IRequestOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { stringifyArrays, registeredAppResponseToApp } from "./helpers.js";
* appType: "multiple",
* redirect_uris: ["http://localhost:3000/"],
* httpReferrers: ["http://localhost:3000/"],
* privileges: [Privileges.Geocode, Privileges.FeatureReport],
* privileges: ["premium:user:geocode:temporary", Privileges.FeatureReport],
* authentication: authSession
* }).then((registeredApp: IApp) => {
* // => {client_id: "xyz_id", client_secret: "xyz_secret", ...}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,23 @@ export interface IDeleteApiKeyResponse {
itemId: string;
success: boolean;
}

export interface IInvalidateApiKeyOptions
extends Omit<IRequestOptions, "params"> {
/**
* {@linkcode IAuthenticationManager} authentication.
*/
authentication: IAuthenticationManager;
/**
* itemId of the item of the API key to be revoked.
*/
itemId: string;
/**
* The API key to be revoked. The full or partial API key or the slot number (1 or 2) can be provided.
*/
apiKey?: string | 1 | 2;
}

export interface IInvalidateApiKeyResponse {
success: boolean;
}
52 changes: 30 additions & 22 deletions packages/arcgis-rest-developer-credentials/src/updateApiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,19 @@ import {
* password: "xyz_pw"
* });
*
* const threeDaysFromToday = new Date();
* threeDaysFromToday.setDate(threeDaysFromToday.getDate() + 3);
* threeDaysFromToday.setHours(23, 59, 59, 999);
*
* updateApiKey({
* itemId: "xyz_itemId",
* privileges: [Privileges.Geocode],
* privileges: ["premium:user:geocode:temporary"],
* httpReferrers: [], // httpReferrers will be set to be empty
* authentication: authSession
* generateToken1: true, // optional,generate a new token
* apiToken1ExpirationDate: threeDaysFromToday // optional, update expiration date
* }).then((updatedAPIKey: IApiKeyResponse) => {
* // => {apiKey: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
* // => {accessToken1: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
* }).catch(e => {
* // => an exception object
* });
Expand Down Expand Up @@ -81,29 +87,31 @@ export async function updateApiKey(
/**
* step 2: update privileges and httpReferrers if provided. Build the object up to avoid overwriting any existing properties.
*/
const getAppOption: IGetAppInfoOptions = {
...baseRequestOptions,
authentication: requestOptions.authentication,
itemId: requestOptions.itemId
};
const appResponse = await getRegisteredAppInfo(getAppOption);
const clientId = appResponse.client_id;
const options = appendCustomParams(
{ ...appResponse, ...requestOptions }, // object with the custom params to look in
["privileges", "httpReferrers"] // keys you want copied to the params object
);
options.params.f = "json";
if (requestOptions.privileges || requestOptions.httpReferrers) {
const getAppOption: IGetAppInfoOptions = {
...baseRequestOptions,
authentication: requestOptions.authentication,
itemId: requestOptions.itemId
};
const appResponse = await getRegisteredAppInfo(getAppOption);
const clientId = appResponse.client_id;
const options = appendCustomParams(
{ ...appResponse, ...requestOptions }, // object with the custom params to look in
["privileges", "httpReferrers"] // keys you want copied to the params object
);
options.params.f = "json";

// encode special params value (e.g. array type...) in advance in order to make encodeQueryString() works correctly
stringifyArrays(options);
// encode special params value (e.g. array type...) in advance in order to make encodeQueryString() works correctly
stringifyArrays(options);

const url = getPortalUrl(options) + `/oauth2/apps/${clientId}/update`;
const url = getPortalUrl(options) + `/oauth2/apps/${clientId}/update`;

// Raw response from `/oauth2/apps/${clientId}/update`, apiKey not included because key is same.
const updateResponse: IRegisteredAppResponse = await request(url, {
...options,
authentication: requestOptions.authentication
});
// Raw response from `/oauth2/apps/${clientId}/update`, apiKey not included because key is same.
const updateResponse: IRegisteredAppResponse = await request(url, {
...options,
authentication: requestOptions.authentication
});
}

/**
* step 3: get the updated item info to return to the user.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { invalidateApiKey } from "../src/invalidateApiKey.js";
import fetchMock from "fetch-mock";
import { IItem } from "@esri/arcgis-rest-portal";
import { IRegisteredAppResponse } from "../src/shared/types/appType.js";
import { TOMORROW } from "../../../scripts/test-helpers.js";
import { ArcGISIdentityManager } from "@esri/arcgis-rest-request";

function setFetchMockPOSTFormUrlencoded(
url: string,
responseBody: any,
status: number,
routeName: string,
repeat: number
): void {
fetchMock.mock(
{
url: url, // url should match
method: "POST", // http method should match
headers: { "Content-Type": "application/x-www-form-urlencoded" }, // content type should match
name: routeName,
repeat: repeat
},
{
body: responseBody,
status: status,
headers: { "Content-Type": "application/json" }
}
);
}

const mockGetAppInfoResponse: IRegisteredAppResponse = {
itemId: "cddcacee5848488bb981e6c6ff91ab79",
client_id: "EiwLuFlkNwE2Ifye",
client_secret: "dc7526de9ece482dba4704618fd3de81",
appType: "apikey",
redirect_uris: [],
registered: 1687824330000,
modified: 1687824330000,
apnsProdCert: null,
apnsSandboxCert: null,
gcmApiKey: null,
httpReferrers: [],
privileges: ["premium:user:geocode:temporary"],
isBeta: false,
isPersonalAPIToken: false,
apiToken1Active: true,
apiToken2Active: false,
customAppLoginShowTriage: false
};

const mockInvaildateApiKeyResponse = {
success: true
};

describe("invalidateApiKey", () => {
// setup IdentityManager
let MOCK_USER_SESSION: ArcGISIdentityManager;

beforeAll(function () {
MOCK_USER_SESSION = new ArcGISIdentityManager({
username: "745062756",
password: "fake-password",
portal: "https://www.arcgis.com/sharing/rest",
token: "fake-token",
tokenExpires: TOMORROW
});
});

afterEach(() => fetchMock.restore());

it("should invalidate an API key", async () => {
setFetchMockPOSTFormUrlencoded(
"https://www.arcgis.com/sharing/rest/content/users/745062756/items/cddcacee5848488bb981e6c6ff91ab79/registeredAppInfo",
mockGetAppInfoResponse,
200,
"getAppRoute",
1
);

setFetchMockPOSTFormUrlencoded(
"https://www.arcgis.com/sharing/rest/oauth2/revokeToken",
mockInvaildateApiKeyResponse,
200,
"invalidateKeyRoute",
1
);

const response = await invalidateApiKey({
itemId: "cddcacee5848488bb981e6c6ff91ab79",
apiKey: 1,
authentication: MOCK_USER_SESSION
});

// verify first fetch
expect(fetchMock.called("invalidateKeyRoute")).toBe(true);
const actualOptionGetAppRoute = fetchMock.lastOptions("invalidateKeyRoute");
expect(actualOptionGetAppRoute.body).toContain("f=json");
expect(actualOptionGetAppRoute.body).not.toContain("token=fake-token");
expect(actualOptionGetAppRoute.body).toContain(
"client_id=EiwLuFlkNwE2Ifye"
);
expect(actualOptionGetAppRoute.body).toContain(
"client_secret=dc7526de9ece482dba4704618fd3de81"
);

expect(response).toEqual({
success: true
});
});
});
Loading