@knowttl/mg-axi 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -1268
- package/axi-credential.schema.json +65 -0
- package/dist/api.js +5 -21
- package/dist/app-auth.js +9 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +26 -20
- package/dist/cli.js +57 -26
- package/dist/credentials.js +78 -0
- package/dist/docs.js +17 -13
- package/dist/doctor.js +10 -8
- package/dist/entra-audit-logs.js +5 -4
- package/dist/entra-conditional-access.js +4 -3
- package/dist/entra-directory-objects.js +4 -5
- package/dist/entra-domains.js +4 -2
- package/dist/entra-group-lifecycle.js +3 -2
- package/dist/entra-roles.js +6 -5
- package/dist/graph-session.js +30 -14
- package/dist/hook.js +9 -32
- package/dist/msal-app-provider.js +27 -101
- package/dist/msal-provider.js +67 -54
- package/dist/mutations.js +42 -53
- package/dist/paths.js +17 -0
- package/dist/private-file.js +227 -0
- package/dist/profiles.js +180 -134
- package/dist/redact.js +26 -0
- package/dist/secrets.js +37 -0
- package/dist/setup.js +2 -2
- package/docs/coverage.md +6 -3
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +15 -11
package/dist/graph-session.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import { AxiError } from "axi-sdk-js";
|
|
5
|
-
import {
|
|
5
|
+
import { redactSecrets } from "./redact.js";
|
|
6
|
+
import { PrivateFiles } from "./private-file.js";
|
|
7
|
+
import { validateProfile, profileName } from "./profiles.js";
|
|
6
8
|
// CORE-01: the single path that owns Graph URL construction, operation
|
|
7
9
|
// authorization, credential attachment and response validation. Command
|
|
8
10
|
// handlers call execute() with a catalogued inventory operation and bound
|
|
@@ -138,7 +140,7 @@ export function redactGraphValue(value) {
|
|
|
138
140
|
}
|
|
139
141
|
function redact(value) {
|
|
140
142
|
if (typeof value === "string")
|
|
141
|
-
return SECRET_VALUE.some(pattern => pattern.test(value)) ? REDACTED : value;
|
|
143
|
+
return SECRET_VALUE.some(pattern => pattern.test(value)) ? REDACTED : redactSecrets(value);
|
|
142
144
|
if (Array.isArray(value))
|
|
143
145
|
return value.map(redact);
|
|
144
146
|
if (value === null || typeof value !== "object")
|
|
@@ -226,6 +228,15 @@ function checkOperation(operation) {
|
|
|
226
228
|
"Resolve the operation from inventory/operations.json before executing it",
|
|
227
229
|
]);
|
|
228
230
|
}
|
|
231
|
+
const unsupported = {
|
|
232
|
+
"/domainDnsRecords": ["mg-axi entra domain verification-dns-record list --domain <domain-id>", "mg-axi entra domain service-configuration-record list --domain <domain-id>"],
|
|
233
|
+
"/directoryObjects": ["mg-axi entra directory-object show --id <object-id>", "Use the named user, group, device, application or service-principal list command"],
|
|
234
|
+
"/scopedRoleMemberships": ["mg-axi entra directory-role scoped-member list --role <role-id>"],
|
|
235
|
+
"/scopedRoleMemberships/{scopedRoleMembership-id}": ["mg-axi entra directory-role scoped-member show --role <role-id> --id <scoped-membership-id>"],
|
|
236
|
+
};
|
|
237
|
+
const alternatives = unsupported[operation.path];
|
|
238
|
+
if (alternatives)
|
|
239
|
+
throw new AxiError(`GET ${operation.path} is not supported by Microsoft Graph v1.0`, "VALIDATION_ERROR", alternatives);
|
|
229
240
|
}
|
|
230
241
|
const UNSAFE_PARAM = /[%/?\\{}()]/;
|
|
231
242
|
const CONTROL = /[\s\x00-\x1f\x7f]/;
|
|
@@ -444,10 +455,10 @@ function faultBody(body) {
|
|
|
444
455
|
try {
|
|
445
456
|
const parsed = redact(JSON.parse(body));
|
|
446
457
|
const code = typeof parsed?.error?.code === "string" ? parsed.error.code : null;
|
|
447
|
-
if (!code)
|
|
448
|
-
return "";
|
|
449
458
|
const message = typeof parsed?.error?.message === "string" ? parsed.error.message.slice(0, 300) : "";
|
|
450
|
-
|
|
459
|
+
if (!code && !message)
|
|
460
|
+
return "";
|
|
461
|
+
return `: graph${code ? ` ${code}` : ""}${message ? ` ${message}` : ""}`;
|
|
451
462
|
}
|
|
452
463
|
catch {
|
|
453
464
|
return "";
|
|
@@ -617,10 +628,13 @@ function valuesOf(operation, body) {
|
|
|
617
628
|
}
|
|
618
629
|
export class GraphSession {
|
|
619
630
|
deps;
|
|
620
|
-
|
|
631
|
+
files;
|
|
632
|
+
constructor(deps, files = new PrivateFiles()) {
|
|
621
633
|
this.deps = deps;
|
|
634
|
+
this.files = files;
|
|
622
635
|
}
|
|
623
636
|
cursorQuery(operation, cursor) {
|
|
637
|
+
checkOperation(operation);
|
|
624
638
|
return decodeCursor(operation, cursor).query;
|
|
625
639
|
}
|
|
626
640
|
async execute(args) {
|
|
@@ -633,7 +647,7 @@ export class GraphSession {
|
|
|
633
647
|
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
634
648
|
]);
|
|
635
649
|
}
|
|
636
|
-
const profile = validateProfile(args.profile);
|
|
650
|
+
const profile = validateProfile(args.profile, this.files);
|
|
637
651
|
this.authorizePolicy(profile, operation);
|
|
638
652
|
const query = args.query ?? {};
|
|
639
653
|
checkQueryContext(query, args.consistencyLevel);
|
|
@@ -645,8 +659,8 @@ export class GraphSession {
|
|
|
645
659
|
let token;
|
|
646
660
|
try {
|
|
647
661
|
token = await beforeDeadline(async () => profile.mode === "delegated"
|
|
648
|
-
? (await this.deps.delegated.credential(
|
|
649
|
-
: (await this.deps.application.credential(
|
|
662
|
+
? (await this.deps.delegated.credential(profile, this.readScopes(args.scopes))).token
|
|
663
|
+
: (await this.deps.application.credential(profile, this.applicationScopes(args.scopes))).token, clock, deadline, signal);
|
|
650
664
|
}
|
|
651
665
|
catch (error) {
|
|
652
666
|
signal?.throwIfAborted();
|
|
@@ -671,7 +685,7 @@ export class GraphSession {
|
|
|
671
685
|
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
672
686
|
]);
|
|
673
687
|
}
|
|
674
|
-
const profile = validateProfile(args.profile);
|
|
688
|
+
const profile = validateProfile(args.profile, this.files);
|
|
675
689
|
this.authorizePolicy(profile, operation);
|
|
676
690
|
checkLimit(args.limit);
|
|
677
691
|
const budget = checkBudget(args.budget);
|
|
@@ -690,8 +704,8 @@ export class GraphSession {
|
|
|
690
704
|
const signal = args.signal;
|
|
691
705
|
const path = buildPath(operation.path, params);
|
|
692
706
|
const context = createHash("sha256").update(JSON.stringify([
|
|
693
|
-
path, profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
|
|
694
|
-
profile.
|
|
707
|
+
path, profileName(profile), profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
|
|
708
|
+
profile.mode === "delegated" ? profile.token : profile.auth === "certificate" ? [profile.privateKey, profile.thumbprintSha256] : profile.auth === "client-secret" ? profile.clientSecret : profile.token, [...new Set(args.scopes ?? [])].sort(),
|
|
695
709
|
])).digest("hex");
|
|
696
710
|
if (resumed && resumed.context !== context)
|
|
697
711
|
throw new AxiError("Collection cursor resource or authentication context does not match", "VALIDATION_ERROR", ["Resume under the original resource bindings, profile identity, credential reference and scopes"]);
|
|
@@ -733,8 +747,8 @@ export class GraphSession {
|
|
|
733
747
|
let token;
|
|
734
748
|
try {
|
|
735
749
|
const credential = await beforeDeadline(async () => profile.mode === "delegated"
|
|
736
|
-
? this.deps.delegated.credential(
|
|
737
|
-
: this.deps.application.credential(
|
|
750
|
+
? this.deps.delegated.credential(profile, this.readScopes(args.scopes))
|
|
751
|
+
: this.deps.application.credential(profile, this.applicationScopes(args.scopes)), clock, deadline, signal);
|
|
738
752
|
const acquiredIdentity = createHash("sha256").update(JSON.stringify([
|
|
739
753
|
profile.mode, credential.tenantId.toLowerCase(), credential.clientId.toLowerCase(),
|
|
740
754
|
profile.mode === "delegated" && "accountId" in credential ? credential.accountId : null,
|
|
@@ -753,6 +767,7 @@ export class GraphSession {
|
|
|
753
767
|
const headersFor = () => ({
|
|
754
768
|
Authorization: `Bearer ${token}`,
|
|
755
769
|
Accept: "application/json",
|
|
770
|
+
"Accept-Language": "en-US",
|
|
756
771
|
"client-request-id": randomUUID(),
|
|
757
772
|
...(consistencyLevel === "eventual" ? { ConsistencyLevel: "eventual" } : {}),
|
|
758
773
|
});
|
|
@@ -938,6 +953,7 @@ export class GraphSession {
|
|
|
938
953
|
const headersFor = () => ({
|
|
939
954
|
Authorization: `Bearer ${token}`,
|
|
940
955
|
Accept: "application/json",
|
|
956
|
+
"Accept-Language": "en-US",
|
|
941
957
|
"client-request-id": randomUUID(),
|
|
942
958
|
...(opts.consistencyLevel === "eventual" ? { ConsistencyLevel: "eventual" } : {}),
|
|
943
959
|
// The branding GET documents Accept-Language as required; 0 selects
|
package/dist/hook.js
CHANGED
|
@@ -1,37 +1,14 @@
|
|
|
1
1
|
import { encode } from "@toon-format/toon";
|
|
2
2
|
import { Profiles } from "./profiles.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// exit code 0, so a missing or broken config never breaks agent startup.
|
|
9
|
-
//
|
|
10
|
-
// Auth-cache presence reads the OS credential store directly: delegated
|
|
11
|
-
// profiles keep their MSAL session blob under the "mg-axi" service with the
|
|
12
|
-
// session key below, so presence means a previous explicit login happened on
|
|
13
|
-
// this machine. Only presence is reported; tokens and key material never
|
|
14
|
-
// reach the output. Application profiles acquire tokens silently per run, so
|
|
15
|
-
// they report the client-credentials mechanism instead of cache state.
|
|
16
|
-
// Session cache key mirrors MsalProvider.application() in
|
|
17
|
-
// src/msal-provider.ts; change both together.
|
|
18
|
-
function delegatedSessionKey(profile) {
|
|
19
|
-
return JSON.stringify([profile.credentialRef.key, profile.tenantId, profile.clientId, profile.cloud]);
|
|
20
|
-
}
|
|
21
|
-
async function probeAuthPresence(profile) {
|
|
3
|
+
import { Credentials } from "./credentials.js";
|
|
4
|
+
import { PrivateFiles } from "./private-file.js";
|
|
5
|
+
import { resolveSecret } from "./secrets.js";
|
|
6
|
+
import { redactSecrets } from "./redact.js";
|
|
7
|
+
async function probeAuthPresence(profile, files = new PrivateFiles()) {
|
|
22
8
|
if (profile.mode !== "delegated")
|
|
23
9
|
return "client-credentials";
|
|
24
|
-
let store;
|
|
25
|
-
try {
|
|
26
|
-
store = await import("keytar").then(module => module.default).catch(() => undefined);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return "unknown";
|
|
30
|
-
}
|
|
31
|
-
if (!store)
|
|
32
|
-
return "unknown";
|
|
33
10
|
try {
|
|
34
|
-
return
|
|
11
|
+
return ("stored" in profile.token ? new Credentials(undefined, files).read(profile) : resolveSecret(profile.token, files)) ? "cached" : "sign-in required";
|
|
35
12
|
}
|
|
36
13
|
catch {
|
|
37
14
|
return "unknown";
|
|
@@ -44,17 +21,17 @@ const CONFIGURED_HINT = "Run npx -y @knowttl/mg-axi doctor --profile <name> to c
|
|
|
44
21
|
const SETUP_HINT = "Run npx -y @knowttl/mg-axi setup to find the config path and example";
|
|
45
22
|
export async function hookSummary(options) {
|
|
46
23
|
try {
|
|
47
|
-
return await renderHookSummary(options);
|
|
24
|
+
return redactSecrets(await renderHookSummary(options));
|
|
48
25
|
}
|
|
49
26
|
catch {
|
|
50
27
|
return "mg: status unavailable\n";
|
|
51
28
|
}
|
|
52
29
|
}
|
|
53
|
-
async function renderHookSummary({ version, env = process.env, configPath, authPresence = probeAuthPresence }) {
|
|
30
|
+
async function renderHookSummary({ version, env = process.env, configPath, files = new PrivateFiles(), authPresence = profile => probeAuthPresence(profile, files) }) {
|
|
54
31
|
let store;
|
|
55
32
|
let names;
|
|
56
33
|
try {
|
|
57
|
-
store = new Profiles(configPath
|
|
34
|
+
store = new Profiles(configPath !== undefined && configPath.trim() !== "" ? configPath.trim() : env.MG_AXI_CONFIG !== undefined && env.MG_AXI_CONFIG.trim() !== "" ? env.MG_AXI_CONFIG.trim() : undefined, files);
|
|
58
35
|
names = store.list().map(item => item.name);
|
|
59
36
|
}
|
|
60
37
|
catch {
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import { lstatSync, readFileSync } from "node:fs";
|
|
2
1
|
import { ConfidentialClientApplication } from "@azure/msal-node";
|
|
3
2
|
import { AxiError } from "axi-sdk-js";
|
|
4
3
|
import { graphAudience } from "./app-auth.js";
|
|
5
|
-
|
|
4
|
+
import { PrivateFiles } from "./private-file.js";
|
|
5
|
+
import { resolveSecret } from "./secrets.js";
|
|
6
|
+
import { registerSecret } from "./redact.js";
|
|
7
|
+
// Application acquisition has no interactive or persistent credential fallback.
|
|
6
8
|
export class MsalApplicationProvider {
|
|
9
|
+
files;
|
|
7
10
|
storage = "session-only";
|
|
11
|
+
constructor(files = new PrivateFiles()) {
|
|
12
|
+
this.files = files;
|
|
13
|
+
}
|
|
8
14
|
async acquire(profile, scopes) {
|
|
9
|
-
const ref = profile.credentialRef;
|
|
10
15
|
const configuration = {
|
|
11
16
|
auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}`,
|
|
12
|
-
...(
|
|
13
|
-
? { clientSecret: this.
|
|
14
|
-
:
|
|
15
|
-
? { clientCertificate: {
|
|
16
|
-
: { clientAssertion: () => this.assertion(
|
|
17
|
+
...(profile.auth === "client-secret"
|
|
18
|
+
? { clientSecret: resolveSecret(profile.clientSecret, this.files) }
|
|
19
|
+
: profile.auth === "certificate"
|
|
20
|
+
? { clientCertificate: { thumbprintSha256: profile.thumbprintSha256, privateKey: this.privateKey(profile) } }
|
|
21
|
+
: { clientAssertion: () => Promise.resolve(this.assertion(profile)) }) },
|
|
17
22
|
system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
|
|
18
23
|
};
|
|
19
24
|
const result = await new ConfidentialClientApplication(configuration)
|
|
@@ -22,104 +27,25 @@ export class MsalApplicationProvider {
|
|
|
22
27
|
throw new Error("Empty application token response");
|
|
23
28
|
if (result.authority.toLowerCase() !== `${configuration.auth.authority.toLowerCase()}/`)
|
|
24
29
|
throw new Error("Configured application authority mismatch");
|
|
25
|
-
return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
|
|
26
|
-
}
|
|
27
|
-
async privateKey(ref) {
|
|
28
|
-
if (ref.keyFile !== undefined) {
|
|
29
|
-
this.storage = "session-only";
|
|
30
|
-
return this.normalizeKey(this.readProtectedFile(ref.keyFile), ref.keyFile);
|
|
31
|
-
}
|
|
32
|
-
if (ref.keyEnv !== undefined) {
|
|
33
|
-
this.storage = "session-only";
|
|
34
|
-
const material = process.env[ref.keyEnv];
|
|
35
|
-
if (typeof material !== "string" || !material.trim())
|
|
36
|
-
throw this.missing(ref.keyEnv);
|
|
37
|
-
return this.normalizeKey(material, ref.keyEnv);
|
|
38
|
-
}
|
|
39
|
-
const store = await import("keytar").then(module => module.default).catch(() => undefined);
|
|
40
|
-
this.storage = store ? "os-protected" : "session-only";
|
|
41
|
-
const material = await store?.getPassword("mg-axi", ref.key).catch(() => null);
|
|
42
|
-
if (typeof material !== "string" || !material.trim())
|
|
43
|
-
throw new Error("Missing referenced certificate private key");
|
|
44
|
-
return material;
|
|
45
|
-
}
|
|
46
|
-
clientSecret(ref) {
|
|
47
|
-
this.storage = "session-only";
|
|
48
|
-
if (ref.secretEnv !== undefined) {
|
|
49
|
-
const secret = process.env[ref.secretEnv];
|
|
50
|
-
if (typeof secret !== "string" || !secret.trim())
|
|
51
|
-
throw this.missing(ref.secretEnv);
|
|
52
|
-
return secret.trim();
|
|
53
|
-
}
|
|
54
|
-
if (ref.secretFile !== undefined)
|
|
55
|
-
return this.readProtectedFile(ref.secretFile);
|
|
56
|
-
throw new Error("Missing client-secret credential holder");
|
|
30
|
+
return { token: registerSecret(result.accessToken), expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
|
|
57
31
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!assertion)
|
|
67
|
-
throw new Error("Missing federated token source");
|
|
68
|
-
return assertion;
|
|
69
|
-
}
|
|
70
|
-
const file = process.env[ref.tokenFileEnv];
|
|
71
|
-
if (!file?.trim())
|
|
72
|
-
throw this.missing(ref.tokenFileEnv);
|
|
73
|
-
// Read on every invocation: projected federated tokens rotate on disk.
|
|
74
|
-
try {
|
|
75
|
-
const assertion = readFileSync(file, "utf8").trim();
|
|
76
|
-
if (!assertion)
|
|
77
|
-
throw this.missing(ref.tokenFileEnv);
|
|
78
|
-
return assertion;
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
if (error instanceof AxiError)
|
|
82
|
-
throw error;
|
|
83
|
-
throw this.missing(ref.tokenFileEnv);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
// Key and secret files hold long-lived material: regular file, owned by the
|
|
87
|
-
// current user, mode 0600, never a symlink. Relative paths resolve against
|
|
88
|
-
// the process working directory at acquire time. Windows skips the
|
|
89
|
-
// ownership and mode checks. Every failure names only the path, never content.
|
|
90
|
-
readProtectedFile(path) {
|
|
91
|
-
try {
|
|
92
|
-
const stat = lstatSync(path);
|
|
93
|
-
if (stat.isSymbolicLink() || !stat.isFile())
|
|
94
|
-
throw this.missing(path);
|
|
95
|
-
if (process.platform !== "win32") {
|
|
96
|
-
if (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
97
|
-
throw this.missing(path);
|
|
98
|
-
if ((stat.mode & 0o777) !== 0o600)
|
|
99
|
-
throw this.missing(path);
|
|
32
|
+
assertion(profile) {
|
|
33
|
+
if ("env" in profile.token && profile.token.env === "MG_AXI_TOKEN" && !process.env.MG_AXI_TOKEN?.trim()) {
|
|
34
|
+
const legacy = process.env.AZURE_FEDERATED_TOKEN_FILE?.trim();
|
|
35
|
+
if (legacy) {
|
|
36
|
+
process.stderr.write("AZURE_FEDERATED_TOKEN_FILE is deprecated; use MG_AXI_TOKEN instead\n");
|
|
37
|
+
if (!this.files.absolute(legacy))
|
|
38
|
+
throw new AxiError("AZURE_FEDERATED_TOKEN_FILE must name an absolute file path", "CONFIG_INVALID", ["Set MG_AXI_TOKEN to the workload assertion"]);
|
|
39
|
+
return resolveSecret({ file: legacy }, this.files);
|
|
100
40
|
}
|
|
101
|
-
const content = readFileSync(path, "utf8").trim();
|
|
102
|
-
if (!content)
|
|
103
|
-
throw this.missing(path);
|
|
104
|
-
return content;
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
if (error instanceof AxiError)
|
|
108
|
-
throw error;
|
|
109
|
-
throw this.missing(path);
|
|
110
41
|
}
|
|
42
|
+
return resolveSecret(profile.token, this.files);
|
|
111
43
|
}
|
|
112
|
-
|
|
113
|
-
|
|
44
|
+
privateKey(profile) {
|
|
45
|
+
const material = resolveSecret(profile.privateKey, this.files);
|
|
114
46
|
const normalized = material.includes("\n") ? material : material.replace(/\\n/g, "\n");
|
|
115
47
|
if (/ENCRYPTED PRIVATE KEY/.test(normalized) || /Proc-Type:\s*4,ENCRYPTED/.test(normalized))
|
|
116
|
-
throw new AxiError(
|
|
117
|
-
|
|
118
|
-
throw this.missing(reference);
|
|
119
|
-
return normalized;
|
|
120
|
-
}
|
|
121
|
-
// Fail-closed holder error: names only the env var name or file path.
|
|
122
|
-
missing(reference) {
|
|
123
|
-
return new AxiError(`Application credential unavailable for the configured reference ${reference}; no user or device-code fallback was attempted`, "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Supply the referenced credential through the named environment variable or file"]);
|
|
48
|
+
throw new AxiError("Referenced privateKey is passphrase-protected; passphrase-protected keys are unsupported", "AUTH_REQUIRED", ["Provision an unencrypted PEM through the referenced environment variable or file"]);
|
|
49
|
+
return registerSecret(normalized);
|
|
124
50
|
}
|
|
125
51
|
}
|
package/dist/msal-provider.js
CHANGED
|
@@ -1,82 +1,95 @@
|
|
|
1
1
|
import { PublicClientApplication } from "@azure/msal-node";
|
|
2
2
|
import open from "open";
|
|
3
|
-
|
|
3
|
+
import { AxiError } from "axi-sdk-js";
|
|
4
|
+
import { profileName } from "./profiles.js";
|
|
5
|
+
import { Credentials } from "./credentials.js";
|
|
6
|
+
import { resolveSecret } from "./secrets.js";
|
|
7
|
+
import { redactSecrets, registerCache, registerSecret } from "./redact.js";
|
|
4
8
|
export class MsalProvider {
|
|
5
|
-
|
|
9
|
+
store;
|
|
10
|
+
storage = "protected-file";
|
|
6
11
|
sessions = new Map();
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
constructor(store = new Credentials()) {
|
|
13
|
+
this.store = store;
|
|
14
|
+
}
|
|
15
|
+
key(profile) { return JSON.stringify([profileName(profile), profile.tenantId, profile.clientId, profile.cloud, profile.token]); }
|
|
16
|
+
application(profile, login = false) {
|
|
17
|
+
const key = this.key(profile);
|
|
18
|
+
this.storage = "stored" in profile.token ? "protected-file" : "session-only";
|
|
9
19
|
const existing = this.sessions.get(key);
|
|
10
|
-
if (existing && !
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
this.storage = store ? "os-protected" : "session-only";
|
|
16
|
-
if (fresh) {
|
|
17
|
-
this.sessions.delete(key);
|
|
18
|
-
if (store) {
|
|
19
|
-
await store.deletePassword("mg-axi", key);
|
|
20
|
-
if (await store.getPassword("mg-axi", key) !== null)
|
|
21
|
-
throw new Error("Unable to wipe profile cache");
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
const configuration = {
|
|
20
|
+
if (existing && !login)
|
|
21
|
+
return existing;
|
|
22
|
+
let loaded = false;
|
|
23
|
+
let snapshot;
|
|
24
|
+
const app = new PublicClientApplication({
|
|
25
25
|
auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}` },
|
|
26
26
|
system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
|
|
27
|
-
|
|
28
|
-
const app = new PublicClientApplication({
|
|
29
|
-
...configuration,
|
|
30
|
-
cache: store ? { cachePlugin: {
|
|
27
|
+
cache: { cachePlugin: {
|
|
31
28
|
beforeCacheAccess: async (context) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
if (!loaded) {
|
|
30
|
+
snapshot = "stored" in profile.token ? this.store.read(profile) : resolveSecret(profile.token, this.store.files);
|
|
31
|
+
loaded = true;
|
|
32
|
+
}
|
|
33
|
+
// MSAL overwrites its cache from the plugin snapshot before a silent lookup.
|
|
34
|
+
if (snapshot)
|
|
35
|
+
context.tokenCache.deserialize(registerCache(snapshot));
|
|
35
36
|
},
|
|
36
37
|
afterCacheAccess: async (context) => {
|
|
37
38
|
if (!context.cacheHasChanged)
|
|
38
39
|
return;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
throw new Error("Unable to wipe profile cache");
|
|
44
|
-
const session = new PublicClientApplication(configuration);
|
|
45
|
-
session.getTokenCache().deserialize(saved);
|
|
46
|
-
this.sessions.set(key, { app: session });
|
|
47
|
-
this.storage = "session-only";
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
await store.setPassword("mg-axi", key, saved);
|
|
40
|
+
snapshot = registerCache(context.tokenCache.serialize());
|
|
41
|
+
// Hosted commands keep refreshed tokens only in memory. Explicit login owns persistence.
|
|
42
|
+
if (login)
|
|
43
|
+
this.store.write(profile, snapshot);
|
|
51
44
|
},
|
|
52
|
-
} }
|
|
45
|
+
} },
|
|
53
46
|
});
|
|
54
|
-
this.sessions.set(key,
|
|
47
|
+
this.sessions.set(key, app);
|
|
55
48
|
return app;
|
|
56
49
|
}
|
|
57
50
|
async login(profile, method, scopes) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
51
|
+
if (!("stored" in profile.token))
|
|
52
|
+
throw new AxiError("auth login requires token: {stored: true}", "CONFIG_INVALID");
|
|
53
|
+
this.sessions.delete(this.key(profile));
|
|
54
|
+
this.store.remove(profile);
|
|
55
|
+
const app = this.application(profile, true);
|
|
56
|
+
try {
|
|
57
|
+
const result = method === "browser"
|
|
58
|
+
? await app.acquireTokenInteractive({ scopes, openBrowser: async (url) => { await open(url); }, prompt: "select_account", errorTemplate: "Sign-in failed. Return to mg-axi for recovery guidance." })
|
|
59
|
+
: await app.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: response => {
|
|
60
|
+
if (typeof response.userCode !== "string" || !response.userCode.trim() || typeof response.deviceCode !== "string" || !response.deviceCode.trim() || !Number.isFinite(response.expiresIn) || response.expiresIn <= 0)
|
|
61
|
+
throw new Error("Invalid device-code challenge");
|
|
62
|
+
process.stderr.write(redactSecrets(`Open https://microsoft.com/devicelogin and enter ${response.userCode}\n`));
|
|
63
|
+
} });
|
|
64
|
+
const credential = this.result(profile, result);
|
|
65
|
+
// Subsequent silent acquisitions must not retain a persistence-enabled plugin.
|
|
66
|
+
this.sessions.delete(this.key(profile));
|
|
67
|
+
return credential;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.sessions.delete(this.key(profile));
|
|
71
|
+
this.store.remove(profile);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
69
74
|
}
|
|
70
75
|
async silent(profile, scopes) {
|
|
71
|
-
const app =
|
|
76
|
+
const app = this.application(profile);
|
|
72
77
|
const accounts = (await app.getTokenCache().getAllAccounts()).filter(account => account.tenantId.toLowerCase() === profile.tenantId.toLowerCase());
|
|
73
78
|
if (accounts.length !== 1)
|
|
74
79
|
throw new Error("Explicit login required");
|
|
75
|
-
|
|
80
|
+
const credential = this.result(profile, await app.acquireTokenSilent({ scopes, account: accounts[0] }));
|
|
81
|
+
if (credential.accountId !== accounts[0].homeAccountId)
|
|
82
|
+
throw new Error("Configured account mismatch");
|
|
83
|
+
return credential;
|
|
84
|
+
}
|
|
85
|
+
async logout(profile) {
|
|
86
|
+
this.sessions.delete(this.key(profile));
|
|
87
|
+
if ("stored" in profile.token)
|
|
88
|
+
this.store.remove(profile);
|
|
76
89
|
}
|
|
77
90
|
result(profile, result) {
|
|
78
91
|
if (!result?.account || result.tenantId.toLowerCase() !== profile.tenantId.toLowerCase())
|
|
79
92
|
throw new Error("Configured tenant mismatch");
|
|
80
|
-
return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: result.tenantId, clientId: profile.clientId, accountId: result.account.homeAccountId };
|
|
93
|
+
return { token: registerSecret(result.accessToken), expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: result.tenantId, clientId: profile.clientId, accountId: result.account.homeAccountId };
|
|
81
94
|
}
|
|
82
95
|
}
|