Add workload identy feature

This commit is contained in:
Jill Regan
2026-06-16 17:30:28 -04:00
parent 3a12b0ab99
commit 46d02bc827
17 changed files with 3409 additions and 47 deletions
+80
View File
@@ -20,6 +20,12 @@ on:
required: true
OP_SERVICE_ACCOUNT_TOKEN:
required: true
OP_WORKLOAD_ID:
required: true
OP_ENVIRONMENT_ID:
required: true
OP_INTEGRATION_KEY:
required: true
VAULT:
description: "1Password vault name or UUID"
required: true
@@ -248,3 +254,77 @@ jobs:
- name: Assert removed secrets [exported env]
if: ${{ matrix.export-env }}
run: ./tests/assert-env-unset.sh
test-workload-identity:
name: Workload Identity (ubuntu-latest, export-env=${{ matrix.export-env }})
runs-on: ubuntu-latest
# Workload Identity exchanges the GitHub OIDC token for 1Password access,
# so the job needs permission to request an OIDC token.
permissions:
id-token: write
contents: read
strategy:
fail-fast: true
matrix:
export-env: [true, false]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Build actions
run: npm run build:all
# No ./configure step and no op:// references: Workload Identity authenticates
# via OIDC and loads all variables from the configured 1Password environment.
- name: Load secrets
id: load_secrets
uses: ./
with:
export-env: ${{ matrix.export-env }}
env:
OP_WORKLOAD_ID: ${{ secrets.OP_WORKLOAD_ID }}
OP_ENVIRONMENT_ID: ${{ secrets.OP_ENVIRONMENT_ID }}
OP_INTEGRATION_KEY: ${{ secrets.OP_INTEGRATION_KEY }}
- name: Assert test secret values [step output]
if: ${{ !matrix.export-env }}
shell: bash
env:
ANOTHER_TEST: ${{ steps.load_secrets.outputs.ANOTHER_TEST }}
SUPER_SECRET: ${{ steps.load_secrets.outputs.SUPER_SECRET }}
TEST_SECRET: ${{ steps.load_secrets.outputs.TEST_SECRET }}
run: ./tests/assert-workload-identity.sh
- name: Assert test secret values [exported env]
if: ${{ matrix.export-env }}
shell: bash
run: ./tests/assert-workload-identity.sh
- name: Remove secrets [exported env]
if: ${{ matrix.export-env }}
uses: ./
with:
unset-previous: true
- name: Assert removed secrets [exported env]
if: ${{ matrix.export-env }}
shell: bash
run: |
for var in ANOTHER_TEST SUPER_SECRET TEST_SECRET; do
if [ -n "$(printenv "$var")" ]; then
echo "Expected secret $var to be unset"
exit 1
fi
done
+3
View File
@@ -92,6 +92,9 @@ jobs:
OP_CONNECT_CREDENTIALS: ${{ secrets.OP_CONNECT_CREDENTIALS }}
OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN }}
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
OP_WORKLOAD_ID: ${{ secrets.OP_WORKLOAD_ID }}
OP_ENVIRONMENT_ID: ${{ secrets.OP_ENVIRONMENT_ID }}
OP_INTEGRATION_KEY: ${{ secrets.OP_INTEGRATION_KEY }}
VAULT: ${{ secrets.VAULT }}
# Post comment on fork PRs after /ok-to-test
+27
View File
@@ -88,6 +88,33 @@ When loading SSH keys, you can specify the format using the `ssh-format` query p
For more details on secret reference syntax, see the [1Password CLI documentation](https://developer.1password.com/docs/cli/secret-reference-syntax/#ssh-format-parameter).
## 🧪 Workload Identity (private beta)
> [!NOTE]
> Workload Identity is in **private beta**. It's available to invited participants only. [Contact 1Password](https://developer.1password.com/joinslack) if you're interested in joining the beta.
Instead of a Service Account token or Connect credentials, you can authenticate using Workload Identity, which exchanges your GitHub Actions OIDC token for short-lived 1Password access — no long-lived secret to store. To use it, set all three of the following environment variables (and do not set `OP_SERVICE_ACCOUNT_TOKEN` or the Connect variables):
```yml
on: push
jobs:
hello-world:
runs-on: ubuntu-latest
permissions:
id-token: write # required for the action to request a GitHub OIDC token
contents: read
steps:
- name: Load secret
id: load_secrets
uses: 1password/load-secrets-action@v5beta
env:
OP_WORKLOAD_ID: ${{ vars.OP_WORKLOAD_ID }}
OP_ENVIRONMENT_ID: ${{ vars.OP_ENVIRONMENT_ID }}
OP_INTEGRATION_KEY: ${{ secrets.OP_INTEGRATION_KEY }}
```
When Workload Identity is configured, secrets are loaded directly from your environment's variables. You don't need to specify individual `op://` secret references. If only some of the three variables are set, or if they're combined with another authentication method, the action fails with a configuration error.
## 💙 Community & Support
- File an [issue](https://github.com/1Password/load-secrets-action/issues) for bugs and feature requests.
+1
View File
@@ -14,6 +14,7 @@ const jestConfig = {
"^@actions/core$": "<rootDir>/__mocks__/actions-core.ts",
"^@actions/tool-cache$": "<rootDir>/__mocks__/actions-tool-cache.ts",
"^@actions/exec$": "<rootDir>/__mocks__/actions-exec.ts",
"^@1password/sdk$": "<rootDir>/__mocks__/1password-sdk.ts",
},
transform: {
".ts": [
BIN
View File
Binary file not shown.
+2931 -33
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@1password/op-js": "^0.1.11",
"@1password/sdk": "0.5.0-beta.1",
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
"@actions/tool-cache": "^4.0.0",
@@ -72,6 +73,21 @@
"prettier": "^2.0.0 || ^3.0.0"
}
},
"node_modules/@1password/sdk": {
"version": "0.5.0-beta.1",
"resolved": "https://registry.npmjs.org/@1password/sdk/-/sdk-0.5.0-beta.1.tgz",
"integrity": "sha512-GY1kcn86qkb39jt20AyOftEu5Tw/Kyq4f84GOHXKRjur4TvqvzdhapynBBosRcBL+kBrc+E8cx7Tp7GEfqAomw==",
"license": "MIT",
"dependencies": {
"@1password/sdk-core": "0.5.0-beta.1"
}
},
"node_modules/@1password/sdk-core": {
"version": "0.5.0-beta.1",
"resolved": "https://registry.npmjs.org/@1password/sdk-core/-/sdk-core-0.5.0-beta.1.tgz",
"integrity": "sha512-61Q2n0kKYXBVAbW5ZVFqtbK1KX3lUfFi8wdsv+UjIVtbFd+X1GpFbLFs+nPtPgX+Z7oc2tTN/czK0S9Cz4oF/A==",
"license": "MIT"
},
"node_modules/@actions/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.0.tgz",
+1
View File
@@ -40,6 +40,7 @@
},
"homepage": "https://github.com/1Password/load-secrets-action#readme",
"dependencies": {
"@1password/sdk": "0.5.0-beta.1",
"@1password/op-js": "^0.1.11",
"@actions/core": "^3.0.0",
"@actions/exec": "^3.0.0",
+1
View File
@@ -0,0 +1 @@
export const createClient = jest.fn();
+1
View File
@@ -11,4 +11,5 @@ module.exports = {
debug: jest.fn(),
addPath: jest.fn(),
isDebug: jest.fn(() => false),
getIDToken: jest.fn(() => Promise.resolve("mock-oidc-token")),
};
+3
View File
@@ -3,5 +3,8 @@ export const envConnectToken = "OP_CONNECT_TOKEN";
export const envServiceAccountToken = "OP_SERVICE_ACCOUNT_TOKEN";
export const envManagedVariables = "OP_MANAGED_VARIABLES";
export const envFilePath = "OP_ENV_FILE";
export const envWorkloadId = "OP_WORKLOAD_ID";
export const envEnvironmentId = "OP_ENVIRONMENT_ID";
export const envIntegrationKey = "OP_INTEGRATION_KEY";
export const authErr = `Authentication error with environment variables: you must set either 1) ${envServiceAccountToken}, or 2) both ${envConnectHost} and ${envConnectToken}.`;
+31 -14
View File
@@ -2,7 +2,13 @@ import dotenv from "dotenv";
import * as core from "@actions/core";
import { validateCli } from "@1password/op-js";
import { installCliOnGithubActionRunner } from "./op-cli-installer";
import { loadSecrets, unsetPrevious, validateAuth } from "./utils";
import {
getWorkloadIdentityConfig,
loadSecrets,
unsetPrevious,
validateAuth,
} from "./utils";
import { loadSecretsFromSDK } from "./sdk-client";
import { envFilePath } from "./constants";
const loadSecretsAction = async () => {
@@ -16,21 +22,32 @@ const loadSecretsAction = async () => {
unsetPrevious();
}
// Validate that a proper authentication configuration is set for the CLI
validateAuth();
const workloadConfig = getWorkloadIdentityConfig();
// Set environment variables from OP_ENV_FILE
const file = process.env[envFilePath];
if (file) {
core.info(`Loading environment variables from file: ${file}`);
dotenv.config({ path: file });
if (workloadConfig) {
await loadSecretsFromSDK(
workloadConfig.workloadId,
workloadConfig.environmentId,
workloadConfig.integrationKey,
shouldExportEnv,
);
} else {
// Validate that a proper authentication configuration is set for the CLI
validateAuth();
// Set environment variables from OP_ENV_FILE
const file = process.env[envFilePath];
if (file) {
core.info(`Loading environment variables from file: ${file}`);
dotenv.config({ path: file });
}
// Download and install the CLI
await installCLI();
// Load secrets
await loadSecrets(shouldExportEnv);
}
// Download and install the CLI
await installCLI();
// Load secrets
await loadSecrets(shouldExportEnv);
} catch (error) {
// It's possible for the Error constructor to be modified to be anything
// in JavaScript, so the following code accounts for this possibility.
+137
View File
@@ -0,0 +1,137 @@
import * as core from "@actions/core";
import { createClient } from "@1password/sdk";
import { envManagedVariables } from "./constants";
import { getOIDCToken, loadSecretsFromSDK } from "./sdk-client";
jest.mock("@1password/sdk");
const mockGetVariables = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(createClient as jest.Mock).mockResolvedValue({
environments: {
getVariables: mockGetVariables,
},
});
});
describe("getOIDCToken", () => {
it("delegates to core.getIDToken", async () => {
(core.getIDToken as jest.Mock).mockResolvedValue("oidc-token");
await expect(getOIDCToken("test-audience")).resolves.toBe("oidc-token");
expect(core.getIDToken).toHaveBeenCalledWith("test-audience");
});
});
describe("loadSecretsFromSDK", () => {
const workloadId = "workload-uuid";
const environmentId = "environment-uuid";
const integrationKey = "integration-key";
const variables = [
{ name: "DOCKERHUB_USERNAME", value: "myuser" },
{ name: "DOCKERHUB_TOKEN", value: "mypassword" },
];
beforeEach(() => {
mockGetVariables.mockResolvedValue({ variables });
});
it("sets secrets as step outputs by default", async () => {
await loadSecretsFromSDK(
workloadId,
environmentId,
integrationKey,
false,
);
expect(core.setOutput).toHaveBeenCalledWith(
"DOCKERHUB_USERNAME",
"myuser",
);
expect(core.setOutput).toHaveBeenCalledWith(
"DOCKERHUB_TOKEN",
"mypassword",
);
expect(core.exportVariable).not.toHaveBeenCalledWith(
"DOCKERHUB_USERNAME",
"myuser",
);
expect(core.setSecret).toHaveBeenCalledWith("myuser");
expect(core.setSecret).toHaveBeenCalledWith("mypassword");
expect(core.exportVariable).not.toHaveBeenCalledWith(
envManagedVariables,
expect.any(String),
);
});
it("exports secrets as environment variables when shouldExportEnv is true", async () => {
await loadSecretsFromSDK(
workloadId,
environmentId,
integrationKey,
true,
);
expect(core.exportVariable).toHaveBeenCalledWith(
"DOCKERHUB_USERNAME",
"myuser",
);
expect(core.exportVariable).toHaveBeenCalledWith(
"DOCKERHUB_TOKEN",
"mypassword",
);
expect(core.setOutput).not.toHaveBeenCalled();
expect(core.exportVariable).toHaveBeenCalledWith(
envManagedVariables,
"DOCKERHUB_USERNAME,DOCKERHUB_TOKEN",
);
});
describe("when secret value is empty string", () => {
beforeEach(() => {
mockGetVariables.mockResolvedValue({
variables: [{ name: "EMPTY_SECRET", value: "" }],
});
});
it("sets empty string as step output", async () => {
await loadSecretsFromSDK(
workloadId,
environmentId,
integrationKey,
false,
);
expect(core.setOutput).toHaveBeenCalledWith("EMPTY_SECRET", "");
expect(core.setSecret).not.toHaveBeenCalled();
});
it("sets empty string as environment variable", async () => {
await loadSecretsFromSDK(
workloadId,
environmentId,
integrationKey,
true,
);
expect(core.exportVariable).toHaveBeenCalledWith("EMPTY_SECRET", "");
expect(core.setSecret).not.toHaveBeenCalled();
});
});
it("does not export OP_MANAGED_VARIABLES when no variables are returned", async () => {
mockGetVariables.mockResolvedValue({ variables: [] });
await loadSecretsFromSDK(
workloadId,
environmentId,
integrationKey,
true,
);
expect(core.exportVariable).not.toHaveBeenCalled();
});
});
+50
View File
@@ -0,0 +1,50 @@
import * as core from "@actions/core";
import { createClient } from "@1password/sdk";
import { version } from "../package.json";
import { envManagedVariables } from "./constants";
export const getOIDCToken = async (audience: string): Promise<string> =>
core.getIDToken(audience);
export const loadSecretsFromSDK = async (
workloadId: string,
environmentId: string,
integrationKey: string,
shouldExportEnv: boolean,
): Promise<void> => {
// Temporary fix: strip base64 padding from integrationKey — this will eventually be handled by the SDK core itself
integrationKey = integrationKey.replace(/=+$/, "");
const client = await createClient({
integrationName: "1Password GitHub Action",
integrationVersion: version,
oidcFetcher: getOIDCToken,
workloadDetails: {
customerManagedSecret: integrationKey,
workloadUuid: workloadId,
},
});
core.info("Authenticated with Workload Identity.");
const { variables } = await client.environments.getVariables(environmentId);
const envNames: string[] = [];
for (const { name, value } of variables) {
core.info(`Populating variable: ${name}`);
if (shouldExportEnv) {
core.exportVariable(name, value);
} else {
core.setOutput(name, value);
}
if (value) {
core.setSecret(value);
}
envNames.push(name);
}
if (shouldExportEnv && envNames.length > 0) {
core.exportVariable(envManagedVariables, envNames.join());
}
};
+66
View File
@@ -3,6 +3,7 @@ import * as exec from "@actions/exec";
import { read, setClientInfo } from "@1password/op-js";
import {
extractSecret,
getWorkloadIdentityConfig,
loadSecrets,
unsetPrevious,
validateAuth,
@@ -11,8 +12,11 @@ import {
authErr,
envConnectHost,
envConnectToken,
envEnvironmentId,
envIntegrationKey,
envManagedVariables,
envServiceAccountToken,
envWorkloadId,
} from "./constants";
jest.mock("@1password/op-js");
@@ -66,6 +70,68 @@ describe("validateAuth", () => {
});
});
describe("getWorkloadIdentityConfig", () => {
const testWorkloadId = "workload-id";
const testEnvironmentId = "environment-id";
const testIntegrationKey = "integration-key";
beforeEach(() => {
process.env[envWorkloadId] = "";
process.env[envEnvironmentId] = "";
process.env[envIntegrationKey] = "";
process.env[envConnectHost] = "";
process.env[envConnectToken] = "";
process.env[envServiceAccountToken] = "";
});
it("should return null when no variables are set", () => {
expect(getWorkloadIdentityConfig()).toBeNull();
});
it("should return the config when all variables are set", () => {
process.env[envWorkloadId] = testWorkloadId;
process.env[envEnvironmentId] = testEnvironmentId;
process.env[envIntegrationKey] = testIntegrationKey;
expect(getWorkloadIdentityConfig()).toEqual({
workloadId: testWorkloadId,
environmentId: testEnvironmentId,
integrationKey: testIntegrationKey,
});
});
it("should throw an error when only some variables are set", () => {
process.env[envWorkloadId] = testWorkloadId;
expect(getWorkloadIdentityConfig).toThrow(
/Incomplete Workload Identity configuration/,
);
});
it("should throw an error when combined with Connect credentials", () => {
process.env[envWorkloadId] = testWorkloadId;
process.env[envEnvironmentId] = testEnvironmentId;
process.env[envIntegrationKey] = testIntegrationKey;
process.env[envConnectHost] = "https://localhost:8000";
process.env[envConnectToken] = "token";
expect(getWorkloadIdentityConfig).toThrow(
/Conflicting authentication configuration/,
);
});
it("should throw an error when combined with a service account token", () => {
process.env[envWorkloadId] = testWorkloadId;
process.env[envEnvironmentId] = testEnvironmentId;
process.env[envIntegrationKey] = testIntegrationKey;
process.env[envServiceAccountToken] = "ops_token";
expect(getWorkloadIdentityConfig).toThrow(
/Conflicting authentication configuration/,
);
});
});
describe("extractSecret", () => {
const envTestSecretEnv = "TEST_SECRET";
const testSecretRef = "op://vault/item/secret";
+45
View File
@@ -8,8 +8,53 @@ import {
envConnectToken,
envServiceAccountToken,
envManagedVariables,
envWorkloadId,
envEnvironmentId,
envIntegrationKey,
} from "./constants";
export interface WorkloadIdentityConfig {
workloadId: string;
environmentId: string;
integrationKey: string;
}
// Returns the Workload Identity configuration when all variables are set,
// or null when none are set (so the CLI auth path can be used instead).
// Throws if the configuration is only partially set, or if it is combined
// with the CLI auth methods (Connect / service account).
export const getWorkloadIdentityConfig = (): WorkloadIdentityConfig | null => {
const workloadId = process.env[envWorkloadId];
const environmentId = process.env[envEnvironmentId];
const integrationKey = process.env[envIntegrationKey];
// None set: fall back to the CLI auth path.
if (!workloadId && !environmentId && !integrationKey) {
return null;
}
// Some but not all set: configuration is incomplete.
if (!workloadId || !environmentId || !integrationKey) {
throw new Error(
`Incomplete Workload Identity configuration. To use Workload Identity, set all of ${envWorkloadId}, ${envEnvironmentId}, and ${envIntegrationKey}.`,
);
}
// Workload Identity is fully configured, so it must not be combined with the
// CLI auth methods (Connect / service account), which are mutually exclusive.
if (
process.env[envConnectHost] ||
process.env[envConnectToken] ||
process.env[envServiceAccountToken]
) {
throw new Error(
`Conflicting authentication configuration: Workload Identity cannot be combined with Connect (${envConnectHost}/${envConnectToken}) or a service account (${envServiceAccountToken}). Set only one authentication method.`,
);
}
return { workloadId, environmentId, integrationKey };
};
export const validateAuth = (): void => {
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
const isServiceAccount = process.env[envServiceAccountToken];
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# shellcheck disable=SC2086
set -e
# Asserts the secrets loaded via Workload Identity.
assert_env_equals() {
if [ "$(printenv $1)" != "$2" ]; then
echo -e "Expected $1 to be set to:\n$2\nBut got:\n$(printenv $1)"
exit 1
fi
}
assert_env_equals "ANOTHER_TEST" "anothertest123"
assert_env_equals "SUPER_SECRET" "supersecret"
assert_env_equals "TEST_SECRET" "thisisatest"