Compare commits
7 Commits
ed5253b1a1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9137688a55 | |||
| a805ed415f | |||
| 1a70f7efcf | |||
| 2645d6c1f4 | |||
| a8725a7c22 | |||
| 0806a2b588 | |||
| 0cde901ec2 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Ignore node modules and config files
|
# Ignore node modules and config files
|
||||||
node_modules
|
node_modules
|
||||||
*config.js
|
*config.js
|
||||||
|
*config.json
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# MacOS system files
|
# MacOS system files
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { ClientSecretCredential } from "@azure/identity";
|
|
||||||
import { config } from "../config.js";
|
|
||||||
import { createHash } from "crypto";
|
|
||||||
|
|
||||||
// We need to wrap the async code in an IIFE
|
|
||||||
// Check, authentication using @azure/identity requires a client secret.
|
|
||||||
if (config.clientSecret) {
|
|
||||||
console.log("Client secret is set.");
|
|
||||||
// Create the client
|
|
||||||
const credential = new ClientSecretCredential(
|
|
||||||
config.tenantId,
|
|
||||||
config.clientId,
|
|
||||||
config.clientSecret,
|
|
||||||
);
|
|
||||||
|
|
||||||
const token = await credential.getToken(
|
|
||||||
"https://management.azure.com/.default",
|
|
||||||
);
|
|
||||||
if (token) {
|
|
||||||
console.log("Authentication with client secret successful.");
|
|
||||||
const hash = createHash("sha256").update(token.token).digest("hex");
|
|
||||||
console.log("SHA-256 hash of access token:", hash);
|
|
||||||
console.log("Token expires on:", new Date(token.expiresOnTimestamp).toISOString());
|
|
||||||
} else {
|
|
||||||
console.error("Authentication with client secret failed.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Warning: No client secret generated. Authentication may fail if the application requires a client secret.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
60
bin/create-app-and-sp.mjs
Normal file
60
bin/create-app-and-sp.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { config } from "../public-config.js";
|
||||||
|
import {
|
||||||
|
createApp,
|
||||||
|
createSp,
|
||||||
|
getApp,
|
||||||
|
getGraphClient,
|
||||||
|
getServicePrincipal,
|
||||||
|
} from "../src/graph.js";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
|
async function usage() {
|
||||||
|
console.log("Usage: create-app-and-sp.mjs --app-name <name>");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { client } = await getGraphClient({
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
clientId: config.clientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = parseArgs({
|
||||||
|
options: {
|
||||||
|
"app-name": {
|
||||||
|
type: "string",
|
||||||
|
short: "n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!args.values["app-name"]) {
|
||||||
|
await usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Will create app with name:", args.values["app-name"]);
|
||||||
|
|
||||||
|
let app = await getApp(client, args.values["app-name"]);
|
||||||
|
if (!app) {
|
||||||
|
app = await createApp(client, args.values["app-name"]);
|
||||||
|
console.log("Created app:", app.appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sp = await getServicePrincipal(client, app.appId);
|
||||||
|
if (!sp) {
|
||||||
|
sp = await createSp(client, app.appId);
|
||||||
|
console.log("Created service principal:", sp.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`The application and associated service principal are ready.
|
||||||
|
App ID: ${app.appId}
|
||||||
|
Service Principal ID: ${sp.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((e) => {
|
||||||
|
console.error("Error in main:", e);
|
||||||
|
console.error(e.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
65
bin/delete-app-and-sp.mjs
Normal file
65
bin/delete-app-and-sp.mjs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { config } from "../public-config.js";
|
||||||
|
import {
|
||||||
|
deleteApp,
|
||||||
|
deleteSp,
|
||||||
|
getApp,
|
||||||
|
getGraphClient,
|
||||||
|
getServicePrincipal,
|
||||||
|
} from "../src/graph.js";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
|
async function usage() {
|
||||||
|
console.log("Usage: delete-app-and-sp.mjs --app-name <name>");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { client } = await getGraphClient({
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
clientId: config.clientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = parseArgs({
|
||||||
|
options: {
|
||||||
|
"app-name": {
|
||||||
|
type: "string",
|
||||||
|
short: "n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!args.values["app-name"]) {
|
||||||
|
await usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Will delete app with name:", args.values["app-name"]);
|
||||||
|
|
||||||
|
const app = await getApp(client, args.values["app-name"]);
|
||||||
|
if (!app) {
|
||||||
|
console.log("No app found with name:", args.values["app-name"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = await getServicePrincipal(client, app.appId);
|
||||||
|
if (sp && sp.id) {
|
||||||
|
await deleteSp(client, sp.id);
|
||||||
|
console.log("Deleted service principal:", sp.id);
|
||||||
|
} else {
|
||||||
|
console.log("No service principal found for appId:", app.appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app && app.id) {
|
||||||
|
await deleteApp(client, app.id);
|
||||||
|
console.log("Deleted app:", app.appId);
|
||||||
|
} else {
|
||||||
|
console.log("App object id missing; cannot delete application");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main().catch((e) => {
|
||||||
|
console.error("Error in main:", e);
|
||||||
|
console.error(e.stack);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
import { loginInteractive } from "../src/azure.js";
|
import { loginInteractive } from "../src/azure.js";
|
||||||
import { config } from "../public-config.js";
|
import { config } from "../public-config.js";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
import { Client } from "@microsoft/microsoft-graph-client";
|
||||||
|
|
||||||
const scopes = ["https://management.azure.com/.default"];
|
// const scopes = ["https://management.azure.com/.default"];
|
||||||
|
const scopes = ["https://graph.microsoft.com/.default"];
|
||||||
|
|
||||||
let token;
|
let token;
|
||||||
|
|
||||||
@@ -15,9 +17,9 @@ try {
|
|||||||
scopes,
|
scopes,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Login failed:", e);
|
console.error("Login failed:", e);
|
||||||
console.error(e.stack);
|
console.error(e.stack);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Access token acquired.");
|
console.log("Access token acquired.");
|
||||||
@@ -26,3 +28,30 @@ console.log("Access token acquired.");
|
|||||||
const hash = createHash("sha256").update(token.accessToken).digest("hex");
|
const hash = createHash("sha256").update(token.accessToken).digest("hex");
|
||||||
console.log("SHA-256 hash of access token:", hash);
|
console.log("SHA-256 hash of access token:", hash);
|
||||||
console.log("Token expires on:", token.expiresOn);
|
console.log("Token expires on:", token.expiresOn);
|
||||||
|
|
||||||
|
const client = Client.init({
|
||||||
|
authProvider: (done) => {
|
||||||
|
done(null, token.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
result = await client
|
||||||
|
.api("/applications")
|
||||||
|
.filter("displayName eq 'Azure Node Playground Public'")
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const apps = result.value ?? [];
|
||||||
|
console.log(
|
||||||
|
`Registered applications with the name 'Azure Node Playground' (${apps.length}):`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (apps.length !== 1) {
|
||||||
|
console.error(
|
||||||
|
"Expected exactly one application with the name 'Azure Node Playground'. Please ensure it is registered in your Azure AD tenant.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Application details:", apps[0]);
|
||||||
|
|||||||
67
bin/login.mjs
Normal file
67
bin/login.mjs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { parseArgs } from "util";
|
||||||
|
import { getCredential } from "../src/azure.js";
|
||||||
|
|
||||||
|
let config = {};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Parse command line arguments to determine which credential type to use
|
||||||
|
const args = parseArgs({
|
||||||
|
options: {
|
||||||
|
help: { type: "boolean", short: "h" },
|
||||||
|
"credential-type": { type: "string", short: "t", default: "default" },
|
||||||
|
"config": { type: "string", short: "c", default: "app-config.json" },
|
||||||
|
"tenant-id": { type: "string", default: process.env.AZURE_TENANT_ID || "" },
|
||||||
|
"client-id": { type: "string", default: process.env.AZURE_CLIENT_ID || "" },
|
||||||
|
"client-secret": { type: "string", default: process.env.AZURE_CLIENT_SECRET || "" },
|
||||||
|
"client-secret-file": { type: "string", default: process.env.AZURE_CLIENT_SECRET_FILE || "" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.values.help) {
|
||||||
|
console.log("Usage: login.mjs [options]");
|
||||||
|
console.log("Options:");
|
||||||
|
console.log(" -h, --help Show this help message");
|
||||||
|
console.log(" -c, --config Path to the configuration file (default: config.js)");
|
||||||
|
console.log(" --tenant-id Azure Tenant ID");
|
||||||
|
console.log(" --client-id Azure Client ID");
|
||||||
|
console.log(" --client-secret Azure Client Secret");
|
||||||
|
console.log(" --client-secret-file Path to file containing Azure Client Secret");
|
||||||
|
console.log(" -t, --credential-type Specify the credential type to use (default: default)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, check if configuration file is spefiedd
|
||||||
|
const configPath = args.values.config;
|
||||||
|
if (configPath) {
|
||||||
|
// Load the JSON configuration file using readFileSync
|
||||||
|
const configFile = readFileSync(configPath, "utf-8");
|
||||||
|
config = JSON.parse(configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process command line overrides
|
||||||
|
if (args.values["tenant-id"]) {
|
||||||
|
config.tenantId = args.values["tenant-id"];
|
||||||
|
}
|
||||||
|
if (args.values["client-id"]) {
|
||||||
|
config.clientId = args.values["client-id"];
|
||||||
|
}
|
||||||
|
if (args.values["client-secret"]) {
|
||||||
|
config.clientSecret = args.values["client-secret"];
|
||||||
|
} else if (args.values["client-secret-file"]) {
|
||||||
|
// Read client secret from file
|
||||||
|
config.clientSecret = readFileSync(args.values["client-secret-file"], "utf-8").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the appropriate credential based on the specified type
|
||||||
|
const credential = await getCredential(args.values["credential-type"], config);
|
||||||
|
|
||||||
|
console.log("Successfully obtained credential:", credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("An error occurred:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import open from "open";
|
import open, { apps } from "open";
|
||||||
|
|
||||||
async function openBrowser(url) {
|
async function openBrowser(url) {
|
||||||
try {
|
try {
|
||||||
await open(url);
|
await open(url, {
|
||||||
|
wait: false,
|
||||||
|
app: {
|
||||||
|
name: apps.edge
|
||||||
|
}
|
||||||
|
});
|
||||||
console.log(`Browser opened to ${url}`);
|
console.log(`Browser opened to ${url}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to open browser: ${error}`);
|
console.error(`Failed to open browser: ${error}`);
|
||||||
|
|||||||
92
src/azure.js
92
src/azure.js
@@ -1,29 +1,68 @@
|
|||||||
// auth.js
|
// azure.js
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import { URL } from "node:url";
|
import { URL } from "node:url";
|
||||||
import open from "open";
|
import open, { apps } from "open";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { PublicClientApplication } from "@azure/msal-node";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import {
|
||||||
|
PublicClientApplication,
|
||||||
|
ConfidentialClientApplication,
|
||||||
|
} from "@azure/msal-node";
|
||||||
|
import {
|
||||||
|
DefaultAzureCredential,
|
||||||
|
ClientSecretCredential,
|
||||||
|
DeviceCodeCredential,
|
||||||
|
} from "@azure/identity";
|
||||||
|
|
||||||
|
export async function getCredential(credentialType, options) {
|
||||||
|
switch (credentialType) {
|
||||||
|
case "d":
|
||||||
|
case "default":
|
||||||
|
return new DefaultAzureCredential();
|
||||||
|
case "cs":
|
||||||
|
case "clientSecret":
|
||||||
|
if (!options.tenantId || !options.clientId || !options.clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
"tenantId, clientId, and clientSecret are required for ClientSecretCredential",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new ClientSecretCredential(
|
||||||
|
options.tenantId,
|
||||||
|
options.clientId,
|
||||||
|
options.clientSecret,
|
||||||
|
);
|
||||||
|
case "dc":
|
||||||
|
case "deviceCode":
|
||||||
|
if (!options.tenantId || !options.clientId) {
|
||||||
|
throw new Error(
|
||||||
|
"tenantId and clientId are required for DeviceCodeCredential",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new DeviceCodeCredential({
|
||||||
|
tenantId: options.tenantId,
|
||||||
|
clientId: options.clientId,
|
||||||
|
userPromptCallback: (info) => {
|
||||||
|
console.log(info.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported credential type: ${credentialType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fileCachePlugin(cachePath) {
|
function fileCachePlugin(cachePath) {
|
||||||
return {
|
return {
|
||||||
beforeCacheAccess: async (ctx) => {
|
beforeCacheAccess: async (ctx) => {
|
||||||
if (fs.existsSync(cachePath)) {
|
if (fs.existsSync(cachePath)) {
|
||||||
ctx.tokenCache.deserialize(
|
ctx.tokenCache.deserialize(fs.readFileSync(cachePath, "utf8"));
|
||||||
fs.readFileSync(cachePath, "utf8")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
afterCacheAccess: async (ctx) => {
|
afterCacheAccess: async (ctx) => {
|
||||||
if (ctx.cacheHasChanged) {
|
if (ctx.cacheHasChanged) {
|
||||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(cachePath, ctx.tokenCache.serialize());
|
||||||
cachePath,
|
|
||||||
ctx.tokenCache.serialize()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -39,16 +78,22 @@ function generatePkce() {
|
|||||||
return { verifier, challenge, challengeMethod: "S256" };
|
return { verifier, challenge, challengeMethod: "S256" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginInteractive({ tenantId, clientId, scopes }) {
|
export async function loginInteractive({ appName, tenantId, clientId, scopes }) {
|
||||||
if (!tenantId) throw new Error("tenantId is required");
|
if (!tenantId) throw new Error("tenantId is required");
|
||||||
if (!clientId) throw new Error("clientId is required");
|
if (!clientId) throw new Error("clientId is required");
|
||||||
if (!Array.isArray(scopes) || scopes.length === 0)
|
if (!Array.isArray(scopes) || scopes.length === 0)
|
||||||
throw new Error("scopes[] is required");
|
throw new Error("scopes[] is required");
|
||||||
|
|
||||||
|
// Make app name lowercase with all non-alphanumeric characters removed
|
||||||
|
// spaces replaced with dashes and all letters converted to lowercase
|
||||||
|
const sanitizedAppName = (appName || "Azure Node Login")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-");
|
||||||
|
|
||||||
const cachePath = path.join(
|
const cachePath = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
".config/azure-node-playground",
|
`.config/${sanitizedAppName}`,
|
||||||
`${clientId}-token-cache.json`
|
`${clientId}-token-cache.json`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pca = new PublicClientApplication({
|
const pca = new PublicClientApplication({
|
||||||
@@ -109,7 +154,9 @@ export async function loginInteractive({ tenantId, clientId, scopes }) {
|
|||||||
|
|
||||||
resolve(token);
|
resolve(token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { server.close(); } catch {}
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {}
|
||||||
reject(e);
|
reject(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -127,12 +174,21 @@ export async function loginInteractive({ tenantId, clientId, scopes }) {
|
|||||||
codeChallengeMethod: pkce.challengeMethod,
|
codeChallengeMethod: pkce.challengeMethod,
|
||||||
});
|
});
|
||||||
|
|
||||||
try { await open(authUrl, { wait: false }); } catch {}
|
try {
|
||||||
console.log("If the browser didn't open, visit:\n" + authUrl);
|
await open(authUrl, {
|
||||||
|
wait: false,
|
||||||
|
app: {
|
||||||
|
name: apps.edge, // Enforce using Microsoft Edge browser
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
console.log("Visit:\n" + authUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { server.close(); } catch {}
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {}
|
||||||
reject(e);
|
reject(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
70
src/graph.js
Normal file
70
src/graph.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Client } from "@microsoft/microsoft-graph-client";
|
||||||
|
import { loginInteractive } from "./azure.js";
|
||||||
|
|
||||||
|
export async function getGraphClient({ tenantId, clientId }) {
|
||||||
|
const graphApiToken = await loginInteractive({
|
||||||
|
tenantId,
|
||||||
|
clientId,
|
||||||
|
scopes: ["https://graph.microsoft.com/.default"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = Client.init({
|
||||||
|
authProvider: (done) => {
|
||||||
|
done(null, graphApiToken.accessToken);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { graphApiToken, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApp(client, appName) {
|
||||||
|
const result = await client
|
||||||
|
.api("/applications")
|
||||||
|
.filter(`displayName eq '${appName}'`)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Return the first application found or null if none exists
|
||||||
|
return result.value.length > 0 ? result.value[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServicePrincipal(client, appId) {
|
||||||
|
const result = await client
|
||||||
|
.api("/servicePrincipals")
|
||||||
|
.filter(`appId eq '${appId}'`)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Return the first service principal found or null if none exists
|
||||||
|
return result.value.length > 0 ? result.value[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApp(client, appName) {
|
||||||
|
const app = await client.api("/applications").post({
|
||||||
|
displayName: appName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app || !app.appId) {
|
||||||
|
throw new Error("Failed to create application");
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSp(client, appId) {
|
||||||
|
const sp = await client.api("/servicePrincipals").post({
|
||||||
|
appId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sp || !sp.id) {
|
||||||
|
throw new Error("Failed to create service principal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSp(client, spId) {
|
||||||
|
await client.api(`/servicePrincipals/${spId}`).delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApp(client, appObjectId) {
|
||||||
|
await client.api(`/applications/${appObjectId}`).delete();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user