Skip to content

Commit 3f9ba48

Browse files
authored
Merge pull request #134 from 1Password/jill/use-sdk-for-service-account
Migrate to use 1Password SDK with Service Account
2 parents 81bc2a5 + 1e8273d commit 3f9ba48

File tree

5 files changed

+279
-17
lines changed

5 files changed

+279
-17
lines changed

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"homepage": "https://github.com/1Password/load-secrets-action#readme",
4242
"dependencies": {
4343
"@1password/op-js": "^0.1.11",
44+
"@1password/sdk": "^0.4.0",
4445
"@actions/core": "^1.10.1",
4546
"@actions/exec": "^1.1.1",
4647
"@actions/tool-cache": "^2.0.2",

src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as core from "@actions/core";
33
import { validateCli } from "@1password/op-js";
44
import { installCliOnGithubActionRunner } from "./op-cli-installer";
55
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
6-
import { envFilePath } from "./constants";
6+
import { envFilePath, envConnectHost, envConnectToken } from "./constants";
77

88
const loadSecretsAction = async () => {
99
try {
@@ -26,8 +26,12 @@ const loadSecretsAction = async () => {
2626
dotenv.config({ path: file });
2727
}
2828

29-
// Download and install the CLI
30-
await installCLI();
29+
const isConnect =
30+
process.env[envConnectHost] && process.env[envConnectToken];
31+
// If Connect is used, download and install the CLI
32+
if (isConnect) {
33+
await installCLI();
34+
}
3135

3236
// Load secrets
3337
await loadSecrets(shouldExportEnv);

src/utils.test.ts

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as core from "@actions/core";
22
import * as exec from "@actions/exec";
33
import { read, setClientInfo } from "@1password/op-js";
4+
import { createClient } from "@1password/sdk";
45
import {
56
extractSecret,
67
loadSecrets,
@@ -22,6 +23,9 @@ jest.mock("@actions/exec", () => ({
2223
})),
2324
}));
2425
jest.mock("@1password/op-js");
26+
jest.mock("@1password/sdk", () => ({
27+
createClient: jest.fn(),
28+
}));
2529

2630
beforeEach(() => {
2731
jest.clearAllMocks();
@@ -143,7 +147,13 @@ describe("extractSecret", () => {
143147
});
144148
});
145149

146-
describe("loadSecrets", () => {
150+
describe("loadSecrets when using Connect", () => {
151+
beforeEach(() => {
152+
process.env[envConnectHost] = "https://localhost:8000";
153+
process.env[envConnectToken] = "token";
154+
process.env[envServiceAccountToken] = "";
155+
});
156+
147157
it("sets the client info and gets the executed output", async () => {
148158
await loadSecrets(true);
149159

@@ -181,6 +191,166 @@ describe("loadSecrets", () => {
181191
});
182192
});
183193

194+
describe("loadSecrets when using Service Account", () => {
195+
const mockResolve = jest.fn();
196+
197+
beforeEach(() => {
198+
process.env[envConnectHost] = "";
199+
process.env[envConnectToken] = "";
200+
process.env[envServiceAccountToken] = "ops_token";
201+
202+
Object.keys(process.env).forEach((key) => {
203+
if (
204+
typeof process.env[key] === "string" &&
205+
process.env[key]?.startsWith("op://")
206+
) {
207+
delete process.env[key];
208+
}
209+
});
210+
process.env.MY_SECRET = "op://vault/item/field";
211+
212+
(createClient as jest.Mock).mockResolvedValue({
213+
secrets: { resolve: mockResolve },
214+
});
215+
216+
mockResolve.mockResolvedValue("resolved-secret-value");
217+
});
218+
219+
it("does not call op env ls when using Service Account", async () => {
220+
await loadSecrets(false);
221+
expect(exec.getExecOutput).not.toHaveBeenCalled();
222+
});
223+
224+
it("sets step output with resolved value when export-env is false", async () => {
225+
await loadSecrets(false);
226+
expect(core.setOutput).toHaveBeenCalledTimes(1);
227+
expect(core.setOutput).toHaveBeenCalledWith(
228+
"MY_SECRET",
229+
"resolved-secret-value",
230+
);
231+
});
232+
233+
it("masks secret with setSecret when export-env is false", async () => {
234+
await loadSecrets(false);
235+
expect(core.setSecret).toHaveBeenCalledTimes(1);
236+
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
237+
});
238+
239+
it("does not call exportVariable when export-env is false", async () => {
240+
await loadSecrets(false);
241+
expect(core.exportVariable).not.toHaveBeenCalled();
242+
});
243+
244+
it("exports env and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
245+
await loadSecrets(true);
246+
expect(core.exportVariable).toHaveBeenCalledWith(
247+
"MY_SECRET",
248+
"resolved-secret-value",
249+
);
250+
expect(core.exportVariable).toHaveBeenCalledWith(
251+
envManagedVariables,
252+
"MY_SECRET",
253+
);
254+
});
255+
256+
it("does not set step output when export-env is true", async () => {
257+
await loadSecrets(true);
258+
expect(core.setOutput).not.toHaveBeenCalledWith(
259+
"MY_SECRET",
260+
expect.anything(),
261+
);
262+
});
263+
264+
it("masks secret with setSecret when export-env is true", async () => {
265+
await loadSecrets(true);
266+
expect(core.setSecret).toHaveBeenCalledTimes(1);
267+
expect(core.setSecret).toHaveBeenCalledWith("resolved-secret-value");
268+
});
269+
270+
it("returns early when no env vars have op:// refs", async () => {
271+
Object.keys(process.env).forEach((key) => {
272+
if (
273+
typeof process.env[key] === "string" &&
274+
process.env[key]?.startsWith("op://")
275+
) {
276+
delete process.env[key];
277+
}
278+
});
279+
await loadSecrets(true);
280+
expect(exec.getExecOutput).not.toHaveBeenCalled();
281+
expect(core.exportVariable).not.toHaveBeenCalled();
282+
});
283+
284+
it("wraps createClient errors with a descriptive message", async () => {
285+
(createClient as jest.Mock).mockRejectedValue(
286+
new Error("invalid token format"),
287+
);
288+
await expect(loadSecrets(false)).rejects.toThrow(
289+
"Service account authentication failed: invalid token format",
290+
);
291+
});
292+
293+
describe("multiple refs", () => {
294+
const ref1 = "op://vault/item/field";
295+
const ref2 = "op://vault/other/item";
296+
const ref3 = "op://vault/file/secret";
297+
298+
beforeEach(() => {
299+
process.env.MY_SECRET = ref1;
300+
process.env.ANOTHER_SECRET = ref2;
301+
process.env.FILE_SECRET = ref3;
302+
303+
mockResolve
304+
.mockResolvedValueOnce("value1")
305+
.mockResolvedValueOnce("value2")
306+
.mockResolvedValueOnce("value3");
307+
});
308+
309+
it("resolves each ref and sets step output for each when export-env is false", async () => {
310+
await loadSecrets(false);
311+
312+
expect(mockResolve).toHaveBeenCalledTimes(3);
313+
expect(mockResolve).toHaveBeenCalledWith(ref1);
314+
expect(mockResolve).toHaveBeenCalledWith(ref2);
315+
expect(mockResolve).toHaveBeenCalledWith(ref3);
316+
317+
expect(core.setOutput).toHaveBeenCalledTimes(3);
318+
expect(core.setOutput).toHaveBeenCalledWith("MY_SECRET", "value1");
319+
expect(core.setOutput).toHaveBeenCalledWith("ANOTHER_SECRET", "value2");
320+
expect(core.setOutput).toHaveBeenCalledWith("FILE_SECRET", "value3");
321+
322+
expect(core.setSecret).toHaveBeenCalledTimes(3);
323+
});
324+
325+
it("resolves each ref and exports each and sets OP_MANAGED_VARIABLES when export-env is true", async () => {
326+
await loadSecrets(true);
327+
328+
expect(mockResolve).toHaveBeenCalledTimes(3);
329+
330+
expect(core.exportVariable).toHaveBeenCalledWith("MY_SECRET", "value1");
331+
expect(core.exportVariable).toHaveBeenCalledWith(
332+
"ANOTHER_SECRET",
333+
"value2",
334+
);
335+
expect(core.exportVariable).toHaveBeenCalledWith("FILE_SECRET", "value3");
336+
337+
const exportVariableCalls = (core.exportVariable as jest.Mock).mock
338+
.calls as [string, string][];
339+
const managedVarsCall = exportVariableCalls.find(
340+
([name]) => name === envManagedVariables,
341+
);
342+
expect(managedVarsCall).toBeDefined();
343+
const managedList = (managedVarsCall as [string, string])[1].split(",");
344+
expect(managedList).toContain("MY_SECRET");
345+
expect(managedList).toContain("ANOTHER_SECRET");
346+
expect(managedList).toContain("FILE_SECRET");
347+
expect(managedList).toHaveLength(3);
348+
349+
expect(core.setSecret).toHaveBeenCalledTimes(3);
350+
});
351+
});
352+
});
353+
184354
describe("unsetPrevious", () => {
185355
const testManagedEnv = "TEST_SECRET";
186356
const testSecretValue = "MyS3cr#T";

src/utils.ts

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as core from "@actions/core";
22
import * as exec from "@actions/exec";
33
import { read, setClientInfo, semverToInt } from "@1password/op-js";
4+
import { createClient } from "@1password/sdk";
45
import { version } from "../package.json";
56
import {
67
authErr,
@@ -29,12 +30,34 @@ export const validateAuth = (): void => {
2930
core.info(`Authenticated with ${authType}.`);
3031
};
3132

32-
export const extractSecret = (
33+
const getEnvVarNamesWithSecretRefs = (): string[] =>
34+
Object.keys(process.env).filter(
35+
(key) =>
36+
typeof process.env[key] === "string" &&
37+
process.env[key]?.startsWith("op://"),
38+
);
39+
40+
const setResolvedSecret = (
3341
envName: string,
42+
secretValue: string,
3443
shouldExportEnv: boolean,
3544
): void => {
3645
core.info(`Populating variable: ${envName}`);
3746

47+
if (shouldExportEnv) {
48+
core.exportVariable(envName, secretValue);
49+
} else {
50+
core.setOutput(envName, secretValue);
51+
}
52+
if (secretValue) {
53+
core.setSecret(secretValue);
54+
}
55+
};
56+
57+
export const extractSecret = (
58+
envName: string,
59+
shouldExportEnv: boolean,
60+
): void => {
3861
const ref = process.env[envName];
3962
if (!ref) {
4063
return;
@@ -45,20 +68,13 @@ export const extractSecret = (
4568
return;
4669
}
4770

48-
if (shouldExportEnv) {
49-
core.exportVariable(envName, secretValue);
50-
} else {
51-
core.setOutput(envName, secretValue);
52-
}
53-
// Skip setSecret for empty strings to avoid the warning:
54-
// "Can't add secret mask for empty string in ##[add-mask] command."
55-
if (secretValue) {
56-
core.setSecret(secretValue);
57-
}
71+
setResolvedSecret(envName, secretValue, shouldExportEnv);
5872
};
5973

60-
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
61-
// Pass User-Agent Information to the 1Password CLI
74+
// Connect loads secrets via the 1Password CLI
75+
const loadSecretsViaConnect = async (
76+
shouldExportEnv: boolean,
77+
): Promise<void> => {
6278
setClientInfo({
6379
name: "1Password GitHub Action",
6480
id: "GHA",
@@ -83,6 +99,61 @@ export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
8399
}
84100
};
85101

102+
// Service Account loads secrets via the 1Password SDK
103+
const loadSecretsViaServiceAccount = async (
104+
shouldExportEnv: boolean,
105+
): Promise<void> => {
106+
const envs = getEnvVarNamesWithSecretRefs();
107+
if (envs.length === 0) {
108+
return;
109+
}
110+
111+
const token = process.env[envServiceAccountToken];
112+
if (!token) {
113+
throw new Error(authErr);
114+
}
115+
116+
// Authenticate with the 1Password SDK
117+
let client;
118+
try {
119+
client = await createClient({
120+
auth: token,
121+
integrationName: "1Password GitHub Action",
122+
integrationVersion: version,
123+
});
124+
} catch (err) {
125+
const message = err instanceof Error ? err.message : String(err);
126+
throw new Error(`Service account authentication failed: ${message}`);
127+
}
128+
129+
for (const envName of envs) {
130+
const ref = process.env[envName];
131+
if (!ref) {
132+
continue;
133+
}
134+
135+
// Resolve the secret value using the 1Password SDK
136+
// and make it available either as step outputs or as environment variables
137+
const secretValue = await client.secrets.resolve(ref);
138+
setResolvedSecret(envName, secretValue, shouldExportEnv);
139+
}
140+
141+
if (shouldExportEnv) {
142+
core.exportVariable(envManagedVariables, envs.join());
143+
}
144+
};
145+
146+
export const loadSecrets = async (shouldExportEnv: boolean): Promise<void> => {
147+
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
148+
149+
if (isConnect) {
150+
await loadSecretsViaConnect(shouldExportEnv);
151+
return;
152+
}
153+
154+
await loadSecretsViaServiceAccount(shouldExportEnv);
155+
};
156+
86157
export const unsetPrevious = (): void => {
87158
if (process.env[envManagedVariables]) {
88159
core.info("Unsetting previous values ...");

0 commit comments

Comments
 (0)