azdo-cli 0.20.0-feature-042-update-notice-command.739 → 0.20.0-feature-043-dpapi-credential-store.742
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.
|
@@ -34,6 +34,21 @@ var CODE_CHALLENGE_METHOD = "S256";
|
|
|
34
34
|
import fs from "fs";
|
|
35
35
|
import path from "path";
|
|
36
36
|
import os from "os";
|
|
37
|
+
|
|
38
|
+
// src/services/credential-store-kind.ts
|
|
39
|
+
var CREDENTIAL_STORES = ["keyring", "dpapi"];
|
|
40
|
+
function parseCredentialStore(value, platform = process.platform) {
|
|
41
|
+
const normalised = value.trim().toLowerCase();
|
|
42
|
+
if (!CREDENTIAL_STORES.includes(normalised)) {
|
|
43
|
+
throw new Error(`Invalid value "${value}" for credentialStore. Must be one of: ${CREDENTIAL_STORES.join(", ")}.`);
|
|
44
|
+
}
|
|
45
|
+
if (normalised === "dpapi" && platform !== "win32") {
|
|
46
|
+
throw new Error('credentialStore "dpapi" is only available on Windows.');
|
|
47
|
+
}
|
|
48
|
+
return normalised;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/services/config-store.ts
|
|
37
52
|
var SETTINGS = [
|
|
38
53
|
{
|
|
39
54
|
key: "org",
|
|
@@ -62,6 +77,13 @@ var SETTINGS = [
|
|
|
62
77
|
type: "boolean",
|
|
63
78
|
example: "true",
|
|
64
79
|
required: false
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "credentialStore",
|
|
83
|
+
description: 'Where credentials are stored: "keyring" (OS vault, default) or "dpapi" (Windows only, ~/.azdo/credentials)',
|
|
84
|
+
type: "string",
|
|
85
|
+
example: "dpapi",
|
|
86
|
+
required: false
|
|
65
87
|
}
|
|
66
88
|
];
|
|
67
89
|
var VALID_KEYS = SETTINGS.map((s) => s.key);
|
|
@@ -128,6 +150,8 @@ function setConfigValue(key, value) {
|
|
|
128
150
|
throw new Error(`Invalid value "${value}" for markdown. Must be "true" or "false".`);
|
|
129
151
|
}
|
|
130
152
|
config.markdown = value === "true";
|
|
153
|
+
} else if (key === "credentialStore") {
|
|
154
|
+
config.credentialStore = parseCredentialStore(value);
|
|
131
155
|
} else if (key === "fields") {
|
|
132
156
|
config.fields = value.split(",").map((s) => s.trim());
|
|
133
157
|
} else {
|
|
@@ -209,7 +233,7 @@ function getOrgScopedValue(org, key) {
|
|
|
209
233
|
}
|
|
210
234
|
function readScope(config, name) {
|
|
211
235
|
if (name === "default") {
|
|
212
|
-
const { org: _o, organizations: _orgs, ...defaults } = config;
|
|
236
|
+
const { org: _o, organizations: _orgs, credentialStore: _cs, ...defaults } = config;
|
|
213
237
|
return defaults;
|
|
214
238
|
}
|
|
215
239
|
return config.organizations?.[name.toLowerCase()] ?? {};
|
|
@@ -476,11 +500,11 @@ function handleCallback(req, res, active) {
|
|
|
476
500
|
return;
|
|
477
501
|
}
|
|
478
502
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
479
|
-
const
|
|
503
|
+
const path4 = url.pathname;
|
|
480
504
|
const q = url.searchParams;
|
|
481
|
-
if (
|
|
482
|
-
writeError(res, 404, `unexpected path ${
|
|
483
|
-
if (active) active.reject(new OAuthFlowError("redirect-mismatch", `unexpected callback path "${
|
|
505
|
+
if (path4 !== "/callback" && path4 !== "/") {
|
|
506
|
+
writeError(res, 404, `unexpected path ${path4}`);
|
|
507
|
+
if (active) active.reject(new OAuthFlowError("redirect-mismatch", `unexpected callback path "${path4}"`));
|
|
484
508
|
return;
|
|
485
509
|
}
|
|
486
510
|
if (!active) {
|
|
@@ -746,10 +770,16 @@ function readAuditEvents() {
|
|
|
746
770
|
import { Entry } from "@napi-rs/keyring";
|
|
747
771
|
|
|
748
772
|
// src/types/credential.ts
|
|
773
|
+
function defaultUnavailableMessage(backend) {
|
|
774
|
+
const base = `OS secret backend unavailable (${backend}). Install the platform's credential service and try again.`;
|
|
775
|
+
if (backend !== "windows-credential-manager") return base;
|
|
776
|
+
return `${base}
|
|
777
|
+
Over SSH, Windows Credential Manager is not reachable. Store credentials with DPAPI in ~/.azdo instead: \`azdo config set credentialStore dpapi\` (or AZDO_CREDENTIAL_STORE=dpapi), then \`azdo auth login\` again.`;
|
|
778
|
+
}
|
|
749
779
|
var CredentialStoreUnavailableError = class extends Error {
|
|
750
780
|
backend;
|
|
751
|
-
constructor(backend, cause) {
|
|
752
|
-
super(
|
|
781
|
+
constructor(backend, cause, message) {
|
|
782
|
+
super(message ?? defaultUnavailableMessage(backend));
|
|
753
783
|
this.name = "CredentialStoreUnavailableError";
|
|
754
784
|
this.backend = backend;
|
|
755
785
|
if (cause instanceof Error) {
|
|
@@ -796,13 +826,147 @@ function normalizePat(rawPat) {
|
|
|
796
826
|
return trimmedPat.length > 0 ? trimmedPat : null;
|
|
797
827
|
}
|
|
798
828
|
|
|
829
|
+
// src/services/dpapi-store.ts
|
|
830
|
+
import fs3 from "fs";
|
|
831
|
+
import os3 from "os";
|
|
832
|
+
import path3 from "path";
|
|
833
|
+
|
|
834
|
+
// src/services/dpapi-binding.ts
|
|
835
|
+
import { createRequire } from "module";
|
|
836
|
+
var cached = null;
|
|
837
|
+
function loadDpapi() {
|
|
838
|
+
if (cached) return cached;
|
|
839
|
+
const require2 = createRequire(import.meta.url);
|
|
840
|
+
const mod = require2("@primno/dpapi");
|
|
841
|
+
if (!mod.isPlatformSupported) {
|
|
842
|
+
throw new Error("the @primno/dpapi native binary is not available for this platform");
|
|
843
|
+
}
|
|
844
|
+
cached = mod.Dpapi;
|
|
845
|
+
return cached;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/services/dpapi-store.ts
|
|
849
|
+
var BACKEND = "windows-dpapi";
|
|
850
|
+
var FILENAME_UNSAFE = /[^A-Za-z0-9_.-]/g;
|
|
851
|
+
function dpapiCredentialsDir() {
|
|
852
|
+
return path3.join(os3.homedir(), ".azdo", "credentials");
|
|
853
|
+
}
|
|
854
|
+
function dpapiFilePath(account) {
|
|
855
|
+
const safe = account.replaceAll(FILENAME_UNSAFE, (ch) => `_${ch.codePointAt(0).toString(16)}`);
|
|
856
|
+
return path3.join(dpapiCredentialsDir(), `${safe}.dpapi`);
|
|
857
|
+
}
|
|
858
|
+
function entropyFor(account) {
|
|
859
|
+
return Buffer.from(`azdo-cli:${account}`, "utf8");
|
|
860
|
+
}
|
|
861
|
+
function unavailable(cause, message) {
|
|
862
|
+
return new CredentialStoreUnavailableError(BACKEND, cause, message);
|
|
863
|
+
}
|
|
864
|
+
function binding() {
|
|
865
|
+
try {
|
|
866
|
+
return loadDpapi();
|
|
867
|
+
} catch (err) {
|
|
868
|
+
throw unavailable(
|
|
869
|
+
err,
|
|
870
|
+
`DPAPI credential store unavailable: ${err.message}. Reinstall azdo-cli, or switch back with \`azdo config set credentialStore keyring\`.`
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
function isNotFound(err) {
|
|
875
|
+
return err.code === "ENOENT";
|
|
876
|
+
}
|
|
877
|
+
var DpapiEntry = class {
|
|
878
|
+
constructor(account) {
|
|
879
|
+
this.account = account;
|
|
880
|
+
this.file = dpapiFilePath(account);
|
|
881
|
+
}
|
|
882
|
+
account;
|
|
883
|
+
file;
|
|
884
|
+
getPassword() {
|
|
885
|
+
let blob;
|
|
886
|
+
try {
|
|
887
|
+
blob = fs3.readFileSync(this.file);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
if (isNotFound(err)) return null;
|
|
890
|
+
throw unavailable(err, `Could not read the DPAPI credential file ${this.file}: ${err.message}`);
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const plain = binding().unprotectData(blob, entropyFor(this.account), "CurrentUser");
|
|
894
|
+
return Buffer.from(plain).toString("utf8");
|
|
895
|
+
} catch (err) {
|
|
896
|
+
if (err instanceof CredentialStoreUnavailableError) throw err;
|
|
897
|
+
throw unavailable(
|
|
898
|
+
err,
|
|
899
|
+
`Could not decrypt the DPAPI credential file ${this.file}: ${err.message}. It was written by another Windows user, on another machine, or from a logon session without access to your DPAPI master key. Run \`azdo auth login\` again from this session to replace it.`
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
setPassword(value) {
|
|
904
|
+
const dpapi = binding();
|
|
905
|
+
const entropy = entropyFor(this.account);
|
|
906
|
+
let blob;
|
|
907
|
+
try {
|
|
908
|
+
blob = dpapi.protectData(Buffer.from(value, "utf8"), entropy, "CurrentUser");
|
|
909
|
+
const check = Buffer.from(dpapi.unprotectData(blob, entropy, "CurrentUser")).toString("utf8");
|
|
910
|
+
if (check !== value) throw new Error("decrypted value does not match");
|
|
911
|
+
} catch (err) {
|
|
912
|
+
throw unavailable(
|
|
913
|
+
err,
|
|
914
|
+
`DPAPI could not protect the credential in this session: ${err.message}. Nothing was stored. Over SSH this usually means the logon has no access to your DPAPI master key (common with public-key authentication); log in with a password, or use AZDO_PAT for this session.`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
918
|
+
try {
|
|
919
|
+
fs3.mkdirSync(dpapiCredentialsDir(), { recursive: true, mode: 448 });
|
|
920
|
+
fs3.writeFileSync(tmp, blob, { mode: 384 });
|
|
921
|
+
fs3.renameSync(tmp, this.file);
|
|
922
|
+
} catch (err) {
|
|
923
|
+
fs3.rmSync(tmp, { force: true });
|
|
924
|
+
throw unavailable(err, `Could not write the DPAPI credential file ${this.file}: ${err.message}`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
deletePassword() {
|
|
928
|
+
try {
|
|
929
|
+
fs3.unlinkSync(this.file);
|
|
930
|
+
return true;
|
|
931
|
+
} catch (err) {
|
|
932
|
+
if (isNotFound(err)) return false;
|
|
933
|
+
throw unavailable(err, `Could not delete the DPAPI credential file ${this.file}: ${err.message}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
799
938
|
// src/services/credential-store.ts
|
|
800
939
|
var SERVICE = "azdo-cli";
|
|
801
940
|
var LEGACY_ACCOUNT = "pat";
|
|
802
941
|
function accountFor(org) {
|
|
803
942
|
return `pat:${org}`;
|
|
804
943
|
}
|
|
944
|
+
function requestedStore() {
|
|
945
|
+
const fromEnv = process.env.AZDO_CREDENTIAL_STORE?.trim();
|
|
946
|
+
if (fromEnv) return fromEnv.toLowerCase();
|
|
947
|
+
let fromConfig;
|
|
948
|
+
try {
|
|
949
|
+
fromConfig = loadConfig()?.credentialStore;
|
|
950
|
+
} catch {
|
|
951
|
+
fromConfig = void 0;
|
|
952
|
+
}
|
|
953
|
+
return fromConfig?.trim().toLowerCase() || "keyring";
|
|
954
|
+
}
|
|
955
|
+
function activeCredentialStore() {
|
|
956
|
+
const requested = requestedStore();
|
|
957
|
+
try {
|
|
958
|
+
return parseCredentialStore(requested);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
const backend = requested === "dpapi" ? "windows-dpapi" : "unknown";
|
|
961
|
+
throw new CredentialStoreUnavailableError(
|
|
962
|
+
backend,
|
|
963
|
+
err,
|
|
964
|
+
`${err.message} Set it with \`azdo config set credentialStore <keyring|dpapi>\` or AZDO_CREDENTIAL_STORE.`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
805
968
|
function probeBackend() {
|
|
969
|
+
if (requestedStore() === "dpapi") return "windows-dpapi";
|
|
806
970
|
switch (process.platform) {
|
|
807
971
|
case "win32":
|
|
808
972
|
return "windows-credential-manager";
|
|
@@ -818,10 +982,12 @@ function wrapUnavailable(fn) {
|
|
|
818
982
|
try {
|
|
819
983
|
return fn();
|
|
820
984
|
} catch (err) {
|
|
985
|
+
if (err instanceof CredentialStoreUnavailableError) throw err;
|
|
821
986
|
throw new CredentialStoreUnavailableError(probeBackend(), err);
|
|
822
987
|
}
|
|
823
988
|
}
|
|
824
989
|
function entryFor(account) {
|
|
990
|
+
if (activeCredentialStore() === "dpapi") return new DpapiEntry(account);
|
|
825
991
|
return wrapUnavailable(() => new Entry(SERVICE, account));
|
|
826
992
|
}
|
|
827
993
|
var legacyUnsetNoticeEmitted = false;
|
|
@@ -897,6 +1063,7 @@ function serializeCredential(cred) {
|
|
|
897
1063
|
return JSON.stringify(cred);
|
|
898
1064
|
}
|
|
899
1065
|
async function maybeMigrateLegacy(targetOrg) {
|
|
1066
|
+
if (activeCredentialStore() === "dpapi") return null;
|
|
900
1067
|
const config = loadConfig();
|
|
901
1068
|
if (!config.org || config.org !== targetOrg) {
|
|
902
1069
|
if (!config.org) {
|
|
@@ -998,7 +1165,7 @@ async function deletePat(org) {
|
|
|
998
1165
|
}
|
|
999
1166
|
try {
|
|
1000
1167
|
const { unlinkSync: unlinkSync2 } = await import("fs");
|
|
1001
|
-
const { lockPath: lockPath2 } = await import("./oauth-token-refresh-
|
|
1168
|
+
const { lockPath: lockPath2 } = await import("./oauth-token-refresh-5O2DYWFR.js");
|
|
1002
1169
|
unlinkSync2(lockPath2(org));
|
|
1003
1170
|
} catch {
|
|
1004
1171
|
}
|
|
@@ -1031,23 +1198,23 @@ var inFlight = /* @__PURE__ */ new Map();
|
|
|
1031
1198
|
function locksDir() {
|
|
1032
1199
|
return join(homedir(), ".azdo", ".locks");
|
|
1033
1200
|
}
|
|
1034
|
-
var
|
|
1201
|
+
var FILENAME_UNSAFE2 = /[^A-Za-z0-9_.-]/g;
|
|
1035
1202
|
function lockPath(org) {
|
|
1036
|
-
const safe = org.replaceAll(
|
|
1203
|
+
const safe = org.replaceAll(FILENAME_UNSAFE2, "_");
|
|
1037
1204
|
return join(locksDir(), `${safe}.refresh`);
|
|
1038
1205
|
}
|
|
1039
1206
|
async function defaultAcquireLock(org, waitMs = DEFAULT_LOCK_WAIT_MS) {
|
|
1040
|
-
const
|
|
1207
|
+
const path4 = lockPath(org);
|
|
1041
1208
|
mkdirSync(locksDir(), { recursive: true, mode: 448 });
|
|
1042
1209
|
const deadline = Date.now() + waitMs;
|
|
1043
1210
|
for (; ; ) {
|
|
1044
1211
|
try {
|
|
1045
|
-
const fd = openSync(
|
|
1212
|
+
const fd = openSync(path4, "wx");
|
|
1046
1213
|
closeSync(fd);
|
|
1047
1214
|
return {
|
|
1048
1215
|
release: () => {
|
|
1049
1216
|
try {
|
|
1050
|
-
unlinkSync(
|
|
1217
|
+
unlinkSync(path4);
|
|
1051
1218
|
} catch {
|
|
1052
1219
|
}
|
|
1053
1220
|
}
|
package/dist/index.js
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
tokenResponseToCredential,
|
|
38
38
|
unsetConfigValue,
|
|
39
39
|
unsetOrgScopedValue
|
|
40
|
-
} from "./chunk-
|
|
40
|
+
} from "./chunk-B76UX3CR.js";
|
|
41
41
|
|
|
42
42
|
// src/program.ts
|
|
43
43
|
import { Command as Command19 } from "commander";
|
|
@@ -2702,7 +2702,8 @@ function createConfigCommand() {
|
|
|
2702
2702
|
"",
|
|
2703
2703
|
"Credentials are NOT stored in the configuration file. They are resolved in this order:",
|
|
2704
2704
|
" 1. the AZDO_PAT environment variable (wins over everything below)",
|
|
2705
|
-
" 2. the OS credential store, per organization (see `azdo auth login`)",
|
|
2705
|
+
" 2. the OS credential store, per organization (see `azdo auth login`) \u2014 or, with",
|
|
2706
|
+
" `credentialStore` set to dpapi (Windows), DPAPI-encrypted files in ~/.azdo/credentials",
|
|
2706
2707
|
" 3. a .env file with AZDO_PAT, searched upwards from the working directory",
|
|
2707
2708
|
"",
|
|
2708
2709
|
"Only AZDO_PAT is read \u2014 AZURE_DEVOPS_PAT, AZURE_DEVOPS_EXT_PAT and AZDO_TOKEN are ignored.",
|
|
@@ -2712,7 +2713,7 @@ function createConfigCommand() {
|
|
|
2712
2713
|
].join("\n")
|
|
2713
2714
|
);
|
|
2714
2715
|
const set = new Command4("set");
|
|
2715
|
-
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
|
|
2716
|
+
set.description("Set a configuration value").argument("<key>", "setting key (org, project, fields, markdown, credentialStore)").argument("<value>", "setting value").option("--org <org>", "set value in an org-scoped configuration").option("--json", "output in JSON format").action((key, value, options) => {
|
|
2716
2717
|
try {
|
|
2717
2718
|
if (options.org) {
|
|
2718
2719
|
setOrgScopedValue(options.org, key, value);
|
|
@@ -2738,7 +2739,7 @@ function createConfigCommand() {
|
|
|
2738
2739
|
}
|
|
2739
2740
|
});
|
|
2740
2741
|
const get = new Command4("get");
|
|
2741
|
-
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
|
|
2742
|
+
get.description("Get a configuration value").argument("<key>", "setting key (org, project, fields, markdown, credentialStore)").option("--org <org>", "read from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
|
|
2742
2743
|
try {
|
|
2743
2744
|
const value = options.org ? getOrgScopedValue(options.org, key) : getConfigValue(key);
|
|
2744
2745
|
if (options.json) {
|
|
@@ -2771,7 +2772,7 @@ function createConfigCommand() {
|
|
|
2771
2772
|
writeConfigList(cfg);
|
|
2772
2773
|
});
|
|
2773
2774
|
const unset = new Command4("unset");
|
|
2774
|
-
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
|
|
2775
|
+
unset.description("Remove a configuration value").argument("<key>", "setting key (org, project, fields, markdown, credentialStore)").option("--org <org>", "remove from an org-scoped configuration").option("--json", "output in JSON format").action((key, options) => {
|
|
2775
2776
|
try {
|
|
2776
2777
|
if (options.org) {
|
|
2777
2778
|
unsetOrgScopedValue(options.org, key);
|
|
@@ -2809,6 +2810,7 @@ function createConfigCommand() {
|
|
|
2809
2810
|
process.stderr.write("Azure DevOps CLI - Configuration Wizard\n");
|
|
2810
2811
|
process.stderr.write("=======================================\n\n");
|
|
2811
2812
|
for (const setting of SETTINGS) {
|
|
2813
|
+
if (setting.key === "credentialStore") continue;
|
|
2812
2814
|
await promptForSetting(cfg, setting, ask);
|
|
2813
2815
|
}
|
|
2814
2816
|
rl.close();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "azdo-cli",
|
|
3
|
-
"version": "0.20.0-feature-
|
|
3
|
+
"version": "0.20.0-feature-043-dpapi-credential-store.742",
|
|
4
4
|
"description": "Azure DevOps CLI tool",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@napi-rs/keyring": "^1.2.0",
|
|
29
|
+
"@primno/dpapi": "^2.0.1",
|
|
29
30
|
"commander": "^14.0.3",
|
|
30
31
|
"jimp": "^1.6.1",
|
|
31
32
|
"node-html-markdown": "^2.0.0"
|