@knowttl/mg-axi 0.1.4 → 0.1.6

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/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
2
  import { LEAVES, operationFor } from "./catalogue.js";
3
3
  import { listUsers } from "./entra-users.js";
4
+ import { authStatus } from "./auth-status.js";
4
5
  // PACK-01: explicit release health check over the reviewed read-only session.
5
6
  // Doctor performs one documented bounded read per profile (entra user list
6
7
  // with a one-row window) and reports configuration, connectivity,
@@ -8,7 +9,7 @@ import { listUsers } from "./entra-users.js";
8
9
  // DelegatedAuth.credential and ApplicationAuth.credential never open a
9
10
  // browser or device-code flow, so doctor never signs in interactively.
10
11
  // Doctor never auto-installs, changes profiles or enables Graph writes;
11
- // silent token refresh may update the protected credential cache.
12
+ // silent token refresh stays in memory.
12
13
  // See README.md for usage.
13
14
  export const DOCTOR_OPERATION = "GET:/users";
14
15
  export const DOCTOR_VERSION = "v1.0";
@@ -16,19 +17,18 @@ export const DOCTOR_CHECK = `entra user list --limit 1 (${DOCTOR_VERSION}:${DOCT
16
17
  export function doctorTargets(store, flag) {
17
18
  if (flag !== undefined)
18
19
  return [store.resolve(flag).name];
19
- const items = store.list();
20
- if (!items.length)
21
- return [];
22
20
  try {
23
21
  return [store.resolve(undefined).name];
24
22
  }
25
- catch {
26
- return items.map(item => item.name);
23
+ catch (error) {
24
+ if (!(error instanceof AxiError) || !["PROFILE_REQUIRED", "PROFILE_AMBIGUOUS"].includes(error.code))
25
+ throw error;
26
+ return store.list().map(item => item.name);
27
27
  }
28
28
  }
29
29
  export async function runDoctor(args) {
30
30
  if (!args.names.length) {
31
- throw new AxiError("No profiles are configured", "AUTH_REQUIRED", ["npx -y @knowttl/mg-axi profile create --help", "npx -y @knowttl/mg-axi setup"]);
31
+ throw new AxiError("No profiles are configured", "PROFILE_REQUIRED", ["npx -y @knowttl/mg-axi profile create --help", "npx -y @knowttl/mg-axi setup"]);
32
32
  }
33
33
  const leaf = LEAVES.find(item => item.path === "entra user list");
34
34
  const operation = leaf === undefined ? undefined : operationFor(leaf, DOCTOR_VERSION);
@@ -39,7 +39,9 @@ export async function runDoctor(args) {
39
39
  for (const name of args.names) {
40
40
  const rerun = `npx -y @knowttl/mg-axi doctor --profile ${name}`;
41
41
  try {
42
- const selected = args.store.resolve(name);
42
+ args.store.verifyPermissions();
43
+ const selected = args.selected ?? args.store.resolve(name);
44
+ authStatus(name, selected.profile);
43
45
  // The same bounded read the named leaf serves: one fetched page, one
44
46
  // surfaced row, remainder buffered in a discarded cursor. Doctor keeps
45
47
  // no cursor and accepts none; recovery reruns doctor, not the read.
@@ -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 { validateApplicationProfile, validateDelegatedProfile, validateProfile, } from "./profiles.js";
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")
@@ -617,8 +619,10 @@ function valuesOf(operation, body) {
617
619
  }
618
620
  export class GraphSession {
619
621
  deps;
620
- constructor(deps) {
622
+ files;
623
+ constructor(deps, files = new PrivateFiles()) {
621
624
  this.deps = deps;
625
+ this.files = files;
622
626
  }
623
627
  cursorQuery(operation, cursor) {
624
628
  return decodeCursor(operation, cursor).query;
@@ -633,7 +637,7 @@ export class GraphSession {
633
637
  "Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
634
638
  ]);
635
639
  }
636
- const profile = validateProfile(args.profile);
640
+ const profile = validateProfile(args.profile, this.files);
637
641
  this.authorizePolicy(profile, operation);
638
642
  const query = args.query ?? {};
639
643
  checkQueryContext(query, args.consistencyLevel);
@@ -645,8 +649,8 @@ export class GraphSession {
645
649
  let token;
646
650
  try {
647
651
  token = await beforeDeadline(async () => profile.mode === "delegated"
648
- ? (await this.deps.delegated.credential(validateDelegatedProfile(profile), this.readScopes(args.scopes))).token
649
- : (await this.deps.application.credential(validateApplicationProfile(profile), this.applicationScopes(args.scopes))).token, clock, deadline, signal);
652
+ ? (await this.deps.delegated.credential(profile, this.readScopes(args.scopes))).token
653
+ : (await this.deps.application.credential(profile, this.applicationScopes(args.scopes))).token, clock, deadline, signal);
650
654
  }
651
655
  catch (error) {
652
656
  signal?.throwIfAborted();
@@ -671,7 +675,7 @@ export class GraphSession {
671
675
  "Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
672
676
  ]);
673
677
  }
674
- const profile = validateProfile(args.profile);
678
+ const profile = validateProfile(args.profile, this.files);
675
679
  this.authorizePolicy(profile, operation);
676
680
  checkLimit(args.limit);
677
681
  const budget = checkBudget(args.budget);
@@ -690,8 +694,8 @@ export class GraphSession {
690
694
  const signal = args.signal;
691
695
  const path = buildPath(operation.path, params);
692
696
  const context = createHash("sha256").update(JSON.stringify([
693
- path, profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
694
- profile.credentialRef, [...new Set(args.scopes ?? [])].sort(),
697
+ path, profileName(profile), profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
698
+ 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
699
  ])).digest("hex");
696
700
  if (resumed && resumed.context !== context)
697
701
  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 +737,8 @@ export class GraphSession {
733
737
  let token;
734
738
  try {
735
739
  const credential = await beforeDeadline(async () => profile.mode === "delegated"
736
- ? this.deps.delegated.credential(validateDelegatedProfile(profile), this.readScopes(args.scopes))
737
- : this.deps.application.credential(validateApplicationProfile(profile), this.applicationScopes(args.scopes)), clock, deadline, signal);
740
+ ? this.deps.delegated.credential(profile, this.readScopes(args.scopes))
741
+ : this.deps.application.credential(profile, this.applicationScopes(args.scopes)), clock, deadline, signal);
738
742
  const acquiredIdentity = createHash("sha256").update(JSON.stringify([
739
743
  profile.mode, credential.tenantId.toLowerCase(), credential.clientId.toLowerCase(),
740
744
  profile.mode === "delegated" && "accountId" in credential ? credential.accountId : null,
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
- // Session-start ambient summary behind the mg-axi-hook entry point.
4
- // Local configuration state only: every configured profile name with its
5
- // tenant label, auth-cache presence and effective write posture, plus the
6
- // package version and one next-step hint. No session, transport, Graph or
7
- // sign-in call of any kind; every failure collapses to a short record with
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 typeof await store.getPassword("mg-axi", delegatedSessionKey(profile)) === "string" ? "cached" : "sign-in required";
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 ?? env.MG_AXI_CONFIG);
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
- // Only this adapter touches application credentials; keys and assertions are referenced, never logged.
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
- ...(ref.provider === "client-secret"
13
- ? { clientSecret: this.clientSecret(ref) }
14
- : ref.provider === "certificate"
15
- ? { clientCertificate: { thumbprint: ref.thumbprint, privateKey: await this.privateKey(ref) } }
16
- : { clientAssertion: () => this.assertion(ref) }) },
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
- async assertion(ref) {
59
- this.storage = "session-only";
60
- if (ref.tokenFileEnv === undefined) {
61
- const file = process.env.AZURE_FEDERATED_TOKEN_FILE;
62
- if (!file?.trim())
63
- throw new Error("Missing federated token source");
64
- // Read on every invocation: projected federated tokens rotate on disk.
65
- const assertion = readFileSync(file, "utf8").trim();
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
- // PEM material from files and env vars; env vars may carry escaped "\n".
113
- normalizeKey(material, reference) {
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(`Application credential reference ${reference} is passphrase-protected; passphrase-protected keys are unsupported`, "AUTH_REQUIRED", ["mg-axi profile show --profile <name>", "Provision an unencrypted PEM through the referenced environment variable or file"]);
117
- if (!normalized.trim())
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
  }
@@ -1,82 +1,95 @@
1
1
  import { PublicClientApplication } from "@azure/msal-node";
2
2
  import open from "open";
3
- // Only this adapter touches Microsoft authentication and OS credential storage.
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
- storage = "session-only";
9
+ store;
10
+ storage = "protected-file";
6
11
  sessions = new Map();
7
- async application(profile, fresh = false) {
8
- const key = JSON.stringify([profile.credentialRef.key, profile.tenantId, profile.clientId, profile.cloud]);
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 && !fresh) {
11
- this.storage = existing.store ? "os-protected" : "session-only";
12
- return existing.app;
13
- }
14
- const store = existing ? existing.store : await import("keytar").then(module => module.default).catch(() => undefined);
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
- const saved = await store.getPassword("mg-axi", key);
33
- if (saved)
34
- context.tokenCache.deserialize(saved);
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
- const saved = context.tokenCache.serialize();
40
- if (process.platform === "win32" && Buffer.byteLength(saved, "utf8") > 2560) {
41
- await store.deletePassword("mg-axi", key);
42
- if (await store.getPassword("mg-axi", key) !== null)
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
- } } : undefined,
45
+ } },
53
46
  });
54
- this.sessions.set(key, { app, store });
47
+ this.sessions.set(key, app);
55
48
  return app;
56
49
  }
57
50
  async login(profile, method, scopes) {
58
- // Each explicit login starts with a new client and a verified empty protected cache.
59
- const app = await this.application(profile, true);
60
- const result = method === "browser"
61
- ? await app.acquireTokenInteractive({ scopes, openBrowser: async (url) => { await open(url); }, prompt: "select_account", errorTemplate: "Sign-in failed. Return to mg-axi for recovery guidance." })
62
- : await app.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: response => {
63
- if (typeof response.userCode !== "string" || !response.userCode.trim() || typeof response.deviceCode !== "string" || !response.deviceCode.trim() || !Number.isFinite(response.expiresIn) || response.expiresIn <= 0)
64
- throw new Error("Invalid device-code challenge");
65
- // The one-time user challenge belongs only to explicit login stderr.
66
- process.stderr.write(`Open https://microsoft.com/devicelogin and enter ${response.userCode}\n`);
67
- } });
68
- return this.result(profile, result);
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 = await this.application(profile);
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
- return this.result(profile, await app.acquireTokenSilent({ scopes, account: accounts[0] }));
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
  }