Skip to content

Commit b1458a2

Browse files
authored
feat(arcgis-rest-developer-credentials): add invalidateApiKey, fix generate API key bug
* feat(arcgis-rest-developer-credentials): add invalidateApiKey, fix generate API key bug * docs(arcgis-rest-developer-credentials): update examples * docs(arcgis-rest-developer-credentials): update invalidate API key example
1 parent 7091d1c commit b1458a2

File tree

11 files changed

+421
-64
lines changed

11 files changed

+421
-64
lines changed

packages/arcgis-rest-developer-credentials/src/createApiKey.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,20 @@ import { getRegisteredAppInfo } from "./shared/getRegisteredAppInfo.js";
3737
* password: "xyz_pw"
3838
* });
3939
*
40+
* const threeDaysFromToday = new Date();
41+
* threeDaysFromToday.setDate(threeDaysFromToday.getDate() + 3);
42+
* threeDaysFromToday.setHours(23, 59, 59, 999);
43+
*
4044
* createApiKey({
4145
* title: "xyz_title",
4246
* description: "xyz_desc",
4347
* tags: ["xyz_tag1", "xyz_tag2"],
4448
* privileges: ["premium:user:networkanalysis:routing"],
45-
* authentication: authSession
49+
* authentication: authSession,
50+
* generateToken1: true, // optional,generate a new token
51+
* apiToken1ExpirationDate: threeDaysFromToday // optional, update expiration date
4652
* }).then((registeredAPIKey: IApiKeyResponse) => {
47-
* // => {apiKey: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
53+
* // => {accessToken1: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
4854
* }).catch(e => {
4955
* // => an exception object
5056
* });

packages/arcgis-rest-developer-credentials/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export * from "./createApiKey.js";
55
export * from "./updateApiKey.js";
66
export * from "./getApiKey.js";
7+
export * from "./invalidateApiKey.js";
78
export * from "./getOAuthApp.js";
89
export * from "./updateOAuthApp.js";
910
export * from "./createOAuthApp.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/* Copyright (c) 2023 Environmental Systems Research Institute, Inc.
2+
* Apache-2.0 */
3+
4+
import {
5+
IInvalidateApiKeyOptions,
6+
IInvalidateApiKeyResponse
7+
} from "./shared/types/apiKeyType.js";
8+
import { getRegisteredAppInfo } from "./shared/getRegisteredAppInfo.js";
9+
import { getPortalUrl } from "@esri/arcgis-rest-portal";
10+
import { request } from "@esri/arcgis-rest-request";
11+
import { slotForInvalidationKey } from "./shared/helpers.js";
12+
13+
/**
14+
* Used to invalidate an API key.
15+
*
16+
* ```js
17+
* import { invalidateApiKey } from "@esri/arcgis-rest-developer-credentials";
18+
*
19+
* invalidateApiKey({
20+
* itemId: ITEM_ID,
21+
* authentication,
22+
* apiKey: 1, // invalidate the key in slot 1
23+
* }).then((response) => {
24+
* // => {success: true}
25+
* }).catch(e => {
26+
* // => an exception object
27+
* });
28+
*/
29+
export async function invalidateApiKey(
30+
requestOptions: IInvalidateApiKeyOptions
31+
): Promise<IInvalidateApiKeyResponse> {
32+
const portal = getPortalUrl(requestOptions);
33+
const url = `${portal}/oauth2/revokeToken`;
34+
35+
const appInfo = await getRegisteredAppInfo({
36+
itemId: requestOptions.itemId,
37+
authentication: requestOptions.authentication
38+
});
39+
40+
const params = {
41+
client_id: appInfo.client_id,
42+
client_secret: appInfo.client_secret,
43+
apiToken: slotForInvalidationKey(requestOptions.apiKey),
44+
regenerateApiToken: true,
45+
grant_type: "client_credentials"
46+
};
47+
48+
// authentication is not being passed to the request because client_secret acts as the auth
49+
return request(url, {
50+
params
51+
});
52+
}

packages/arcgis-rest-developer-credentials/src/shared/generateApiKeyToken.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export async function generateApiKeyToken(
3232
grant_type: "client_credentials"
3333
};
3434

35+
// authentication is not being passed to the request because client_secret acts as the auth
3536
return request(url, {
36-
authentication: options.authentication,
3737
params
3838
});
3939
}

packages/arcgis-rest-developer-credentials/src/shared/helpers.ts

+27-2
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,35 @@ export function filterKeys<T extends object>(
121121
}
122122

123123
/**
124-
* Used to determine if a generated key is in slot 1 or slot 2 key.
124+
* 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.
125125
*/
126126
export function slotForKey(key: string) {
127-
return parseInt(key.substring(key.length - 10, key.length - 9));
127+
const slot = parseInt(key.substring(key.length - 10, key.length - 9));
128+
129+
if (slot === 1 || slot === 2) {
130+
return slot;
131+
}
132+
133+
return undefined;
134+
}
135+
136+
/**
137+
* @internal
138+
* Used to determine which slot to invalidate a key in given a number or a full or patial key.
139+
*/
140+
export function slotForInvalidationKey(param: string | 1 | 2) {
141+
if (param === 1 || param === 2) {
142+
return param;
143+
}
144+
145+
if (typeof param !== "string") {
146+
return undefined;
147+
}
148+
149+
const fullKeySlot = slotForKey(param);
150+
if (fullKeySlot) {
151+
return fullKeySlot;
152+
}
128153
}
129154

130155
interface IGenerateApiKeyTokenOptions extends IRequestOptions {

packages/arcgis-rest-developer-credentials/src/shared/registerApp.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { stringifyArrays, registeredAppResponseToApp } from "./helpers.js";
3434
* appType: "multiple",
3535
* redirect_uris: ["http://localhost:3000/"],
3636
* httpReferrers: ["http://localhost:3000/"],
37-
* privileges: [Privileges.Geocode, Privileges.FeatureReport],
37+
* privileges: ["premium:user:geocode:temporary", Privileges.FeatureReport],
3838
* authentication: authSession
3939
* }).then((registeredApp: IApp) => {
4040
* // => {client_id: "xyz_id", client_secret: "xyz_secret", ...}

packages/arcgis-rest-developer-credentials/src/shared/types/apiKeyType.ts

+20
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,23 @@ export interface IDeleteApiKeyResponse {
141141
itemId: string;
142142
success: boolean;
143143
}
144+
145+
export interface IInvalidateApiKeyOptions
146+
extends Omit<IRequestOptions, "params"> {
147+
/**
148+
* {@linkcode IAuthenticationManager} authentication.
149+
*/
150+
authentication: IAuthenticationManager;
151+
/**
152+
* itemId of the item of the API key to be revoked.
153+
*/
154+
itemId: string;
155+
/**
156+
* The API key to be revoked. The full or partial API key or the slot number (1 or 2) can be provided.
157+
*/
158+
apiKey?: string | 1 | 2;
159+
}
160+
161+
export interface IInvalidateApiKeyResponse {
162+
success: boolean;
163+
}

packages/arcgis-rest-developer-credentials/src/updateApiKey.ts

+30-22
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,19 @@ import {
3939
* password: "xyz_pw"
4040
* });
4141
*
42+
* const threeDaysFromToday = new Date();
43+
* threeDaysFromToday.setDate(threeDaysFromToday.getDate() + 3);
44+
* threeDaysFromToday.setHours(23, 59, 59, 999);
45+
*
4246
* updateApiKey({
4347
* itemId: "xyz_itemId",
44-
* privileges: [Privileges.Geocode],
48+
* privileges: ["premium:user:geocode:temporary"],
4549
* httpReferrers: [], // httpReferrers will be set to be empty
4650
* authentication: authSession
51+
* generateToken1: true, // optional,generate a new token
52+
* apiToken1ExpirationDate: threeDaysFromToday // optional, update expiration date
4753
* }).then((updatedAPIKey: IApiKeyResponse) => {
48-
* // => {apiKey: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
54+
* // => {accessToken1: "xyz_key", item: {tags: ["xyz_tag1", "xyz_tag2"], ...}, ...}
4955
* }).catch(e => {
5056
* // => an exception object
5157
* });
@@ -81,29 +87,31 @@ export async function updateApiKey(
8187
/**
8288
* step 2: update privileges and httpReferrers if provided. Build the object up to avoid overwriting any existing properties.
8389
*/
84-
const getAppOption: IGetAppInfoOptions = {
85-
...baseRequestOptions,
86-
authentication: requestOptions.authentication,
87-
itemId: requestOptions.itemId
88-
};
89-
const appResponse = await getRegisteredAppInfo(getAppOption);
90-
const clientId = appResponse.client_id;
91-
const options = appendCustomParams(
92-
{ ...appResponse, ...requestOptions }, // object with the custom params to look in
93-
["privileges", "httpReferrers"] // keys you want copied to the params object
94-
);
95-
options.params.f = "json";
90+
if (requestOptions.privileges || requestOptions.httpReferrers) {
91+
const getAppOption: IGetAppInfoOptions = {
92+
...baseRequestOptions,
93+
authentication: requestOptions.authentication,
94+
itemId: requestOptions.itemId
95+
};
96+
const appResponse = await getRegisteredAppInfo(getAppOption);
97+
const clientId = appResponse.client_id;
98+
const options = appendCustomParams(
99+
{ ...appResponse, ...requestOptions }, // object with the custom params to look in
100+
["privileges", "httpReferrers"] // keys you want copied to the params object
101+
);
102+
options.params.f = "json";
96103

97-
// encode special params value (e.g. array type...) in advance in order to make encodeQueryString() works correctly
98-
stringifyArrays(options);
104+
// encode special params value (e.g. array type...) in advance in order to make encodeQueryString() works correctly
105+
stringifyArrays(options);
99106

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

102-
// Raw response from `/oauth2/apps/${clientId}/update`, apiKey not included because key is same.
103-
const updateResponse: IRegisteredAppResponse = await request(url, {
104-
...options,
105-
authentication: requestOptions.authentication
106-
});
109+
// Raw response from `/oauth2/apps/${clientId}/update`, apiKey not included because key is same.
110+
const updateResponse: IRegisteredAppResponse = await request(url, {
111+
...options,
112+
authentication: requestOptions.authentication
113+
});
114+
}
107115

108116
/**
109117
* step 3: get the updated item info to return to the user.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { invalidateApiKey } from "../src/invalidateApiKey.js";
2+
import fetchMock from "fetch-mock";
3+
import { IItem } from "@esri/arcgis-rest-portal";
4+
import { IRegisteredAppResponse } from "../src/shared/types/appType.js";
5+
import { TOMORROW } from "../../../scripts/test-helpers.js";
6+
import { ArcGISIdentityManager } from "@esri/arcgis-rest-request";
7+
8+
function setFetchMockPOSTFormUrlencoded(
9+
url: string,
10+
responseBody: any,
11+
status: number,
12+
routeName: string,
13+
repeat: number
14+
): void {
15+
fetchMock.mock(
16+
{
17+
url: url, // url should match
18+
method: "POST", // http method should match
19+
headers: { "Content-Type": "application/x-www-form-urlencoded" }, // content type should match
20+
name: routeName,
21+
repeat: repeat
22+
},
23+
{
24+
body: responseBody,
25+
status: status,
26+
headers: { "Content-Type": "application/json" }
27+
}
28+
);
29+
}
30+
31+
const mockGetAppInfoResponse: IRegisteredAppResponse = {
32+
itemId: "cddcacee5848488bb981e6c6ff91ab79",
33+
client_id: "EiwLuFlkNwE2Ifye",
34+
client_secret: "dc7526de9ece482dba4704618fd3de81",
35+
appType: "apikey",
36+
redirect_uris: [],
37+
registered: 1687824330000,
38+
modified: 1687824330000,
39+
apnsProdCert: null,
40+
apnsSandboxCert: null,
41+
gcmApiKey: null,
42+
httpReferrers: [],
43+
privileges: ["premium:user:geocode:temporary"],
44+
isBeta: false,
45+
isPersonalAPIToken: false,
46+
apiToken1Active: true,
47+
apiToken2Active: false,
48+
customAppLoginShowTriage: false
49+
};
50+
51+
const mockInvaildateApiKeyResponse = {
52+
success: true
53+
};
54+
55+
describe("invalidateApiKey", () => {
56+
// setup IdentityManager
57+
let MOCK_USER_SESSION: ArcGISIdentityManager;
58+
59+
beforeAll(function () {
60+
MOCK_USER_SESSION = new ArcGISIdentityManager({
61+
username: "745062756",
62+
password: "fake-password",
63+
portal: "https://www.arcgis.com/sharing/rest",
64+
token: "fake-token",
65+
tokenExpires: TOMORROW
66+
});
67+
});
68+
69+
afterEach(() => fetchMock.restore());
70+
71+
it("should invalidate an API key", async () => {
72+
setFetchMockPOSTFormUrlencoded(
73+
"https://www.arcgis.com/sharing/rest/content/users/745062756/items/cddcacee5848488bb981e6c6ff91ab79/registeredAppInfo",
74+
mockGetAppInfoResponse,
75+
200,
76+
"getAppRoute",
77+
1
78+
);
79+
80+
setFetchMockPOSTFormUrlencoded(
81+
"https://www.arcgis.com/sharing/rest/oauth2/revokeToken",
82+
mockInvaildateApiKeyResponse,
83+
200,
84+
"invalidateKeyRoute",
85+
1
86+
);
87+
88+
const response = await invalidateApiKey({
89+
itemId: "cddcacee5848488bb981e6c6ff91ab79",
90+
apiKey: 1,
91+
authentication: MOCK_USER_SESSION
92+
});
93+
94+
// verify first fetch
95+
expect(fetchMock.called("invalidateKeyRoute")).toBe(true);
96+
const actualOptionGetAppRoute = fetchMock.lastOptions("invalidateKeyRoute");
97+
expect(actualOptionGetAppRoute.body).toContain("f=json");
98+
expect(actualOptionGetAppRoute.body).not.toContain("token=fake-token");
99+
expect(actualOptionGetAppRoute.body).toContain(
100+
"client_id=EiwLuFlkNwE2Ifye"
101+
);
102+
expect(actualOptionGetAppRoute.body).toContain(
103+
"client_secret=dc7526de9ece482dba4704618fd3de81"
104+
);
105+
106+
expect(response).toEqual({
107+
success: true
108+
});
109+
});
110+
});

0 commit comments

Comments
 (0)