Migrate connect to use SDK

This commit is contained in:
Jill Regan
2026-02-20 08:24:08 -05:00
parent ab44f9f69c
commit ffffc2db51
5 changed files with 471 additions and 59 deletions

View File

@@ -1,7 +1,7 @@
import * as core from "@actions/core";
import * as exec from "@actions/exec";
import { read, setClientInfo, semverToInt } from "@1password/op-js";
import { read } from "@1password/op-js";
import { createClient, Secrets } from "@1password/sdk";
import { OnePasswordConnect, FullItem, OPConnect } from "@1password/connect";
import { version } from "../package.json";
import {
authErr,
@@ -11,6 +11,242 @@ import {
envManagedVariables,
} from "./constants";
// Types for parsed op ref
export interface ParsedOpRef {
vault: string;
item: string;
section: string | undefined;
field: string;
}
const parseOpRef = (ref: string): ParsedOpRef => {
// Safety check: refs are validated by validateSecretRefs before this runs
// this guards against parseOpRef being called directly with invalid input
if (!ref.startsWith("op://")) {
throw new Error(`Invalid op reference: ${ref}`);
}
const segments = ref
.slice("op://".length)
.split("/")
.map((s) => decodeURIComponent(s));
if (segments.length < 3 || segments.length > 4) {
throw new Error(
`Invalid op reference: use op://<vault>/<item>/<field> or op://<vault>/<item>/<section>/<field>. Got: ${ref}`,
);
}
const vault = segments[0] ?? "";
if (!vault) {
throw new Error(`Invalid op reference: vault is required`);
}
const item = segments[1] ?? "";
if (!item) {
throw new Error(`Invalid op reference: item is required`);
}
// Last segment is always the field
const field = segments[segments.length - 1] ?? "";
if (!field) {
throw new Error(`Invalid op reference: field is required`);
}
// Second to last segment is the section if it exists
let section: string | undefined;
if (segments.length === 4) {
section = segments[2];
if (!section) {
throw new Error(`Invalid op reference: section is required when using 4 path segments`);
}
}
return {
vault,
item,
field,
section,
};
}
const getSecretFromConnectItem = async (
client: OPConnect,
item: FullItem,
parsed: ParsedOpRef,
): Promise<string> => {
const sectionIds = parsed.section ? findSectionIdsByQuery(item.sections, parsed.section) : [];
const { fieldValue, fileId } = findMatchingFieldAndFile(item, parsed.field, sectionIds);
if (fieldValue !== undefined) {
return fieldValue;
}
// If a file was found, get the content of the file
if (fileId) {
const content = await client.getFileContent(
parsed.vault,
parsed.item,
fileId,
);
return content;
}
if (parsed.section) {
throw new Error(
`could not find field or file ${parsed.field} in section ${parsed.section} on item ${parsed.item} in vault ${parsed.vault}`,
);
}
throw new Error(
`could not find field or file ${parsed.field} on item ${parsed.item} in vault ${parsed.vault}`,
);
}
const findSectionIdsByQuery = (
sections: FullItem["sections"],
sectionQuery: string | undefined,
): string[] => {
// If no sections were returned with the item throw an error
if (!sections || sections.length === 0) {
throw new Error(
`section ${sectionQuery} could not be found in specified item`,
);
}
const ids = sections
.filter((s) => s.id === sectionQuery || s.label === sectionQuery)
.map((s) => s.id!)
.filter(Boolean);
// If no sections were found with the given query throw an error
if (ids.length === 0) {
throw new Error(
`section ${sectionQuery} could not be found in specified item`,
);
}
return ids;
};
const findMatchingFieldAndFile = (
item: FullItem,
fieldOrFileQuery: string,
sectionIds: string[],
): { fieldValue?: string; fileId?: string } => {
const errMultiple = `multiple fields ${fieldOrFileQuery} that match the provided reference have been found`;
const fields = item.fields ?? [];
const files = item.files ?? [];
const sectionFilter = sectionIds.length > 0;
let matchedField: (typeof fields)[0] | undefined;
let matchedFile: (typeof files)[0] | undefined;
if (sectionFilter) {
// Filter fields by section
const matchingFields = fields.filter((f) => {
const fieldIdOrLabelMatchesQuery = f.id === fieldOrFileQuery || f.label === fieldOrFileQuery;
const sectionId = f.section?.id;
const fieldSectionIsInRefSections = sectionId != null && sectionIds.includes(sectionId);
return fieldIdOrLabelMatchesQuery && fieldSectionIsInRefSections;
});
// If multiple fields match the query throw an error otherwise set first matching field
if (matchingFields.length > 1) {
throw new Error(errMultiple);
}
matchedField = matchingFields[0];
const matchingFiles = files.filter((f) => {
const fileIdOrNameMatchesQuery = f.id === fieldOrFileQuery || f.name === fieldOrFileQuery;
const sectionId = f.section?.id;
const fileSectionIsInRefSections = sectionId != null && sectionIds.includes(sectionId);
return fileIdOrNameMatchesQuery && fileSectionIsInRefSections;
});
// If multiple files match the query throw an error otherwise set first matching file
if (matchingFiles.length > 1) {
throw new Error(errMultiple);
}
matchedFile = matchingFiles[0];
} else {
let matchingFields = fields.filter((f) => {
const fieldIdOrLabelMatchesQuery = f.id === fieldOrFileQuery || f.label === fieldOrFileQuery;
const fieldHasNoSection = f.section?.id == null;
return fieldIdOrLabelMatchesQuery && fieldHasNoSection;
});
// If multiple fields match the query throw an error otherwise set first matching field
if (matchingFields.length > 1) {
throw new Error(errMultiple);
}
matchedField = matchingFields[0];
// If no field was found with no section, find a field in any section
if (!matchedField) {
const matchingFieldsInAnySection = fields.filter((f) => {
const fieldIdOrLabelMatchesQuery =f.id === fieldOrFileQuery || f.label === fieldOrFileQuery;
return fieldIdOrLabelMatchesQuery;
});
if (matchingFieldsInAnySection.length > 1) {
throw new Error(errMultiple);
}
matchedField = matchingFieldsInAnySection[0];
}
let matchingFiles = files.filter((f) => {
const fileIdOrNameMatchesQuery = f.id === fieldOrFileQuery || f.name === fieldOrFileQuery;
const fileHasNoSection = f.section?.id == null;
return fileIdOrNameMatchesQuery && fileHasNoSection;
});
// If multiple files match the query throw an error otherwise set first matching file
if (matchingFiles.length > 1) {
throw new Error(errMultiple);
}
matchedFile = matchingFiles[0];
// If no file was found with no section, find a file in any section
if (!matchedFile) {
const matchingFilesInAnySection = files.filter((f) => {
const fileIdOrNameMatchesQuery = f.id === fieldOrFileQuery || f.name === fieldOrFileQuery;
return fileIdOrNameMatchesQuery;
});
if (matchingFilesInAnySection.length > 1) {
throw new Error(errMultiple);
}
matchedFile = matchingFilesInAnySection[0];
}
}
if (matchedField && matchedFile) {
throw new Error(
`you cannot query fields/files that are identically named.`,
);
}
if (matchedField) {
if (matchedField.value === undefined || matchedField.value === null) {
throw new Error(
`field ${fieldOrFileQuery} has no value in specified item`,
);
}
return { fieldValue: matchedField.value };
}
if (matchedFile?.id) {
const fileId = matchedFile.id;
return { fileId };
}
return {};
}
export const validateAuth = (): void => {
const isConnect = process.env[envConnectHost] && process.env[envConnectToken];
const isServiceAccount = process.env[envServiceAccountToken];
@@ -105,29 +341,50 @@ export const extractSecret = (
}
};
// Connect loads secrets via the 1Password CLI
// Connect loads secrets via the Connect JS SDK
const loadSecretsViaConnect = async (
shouldExportEnv: boolean,
): Promise<void> => {
setClientInfo({
name: "1Password GitHub Action",
id: "GHA",
build: semverToInt(version),
});
// Load secrets from environment variables using 1Password CLI.
// Iterate over them to find 1Password references, extract the secret values,
// and make them available in the next steps either as step outputs or as environment variables.
const res = await exec.getExecOutput(`sh -c "op env ls"`);
if (res.stdout === "") {
const envs = getEnvVarNamesWithSecretRefs();
if (envs.length === 0) {
return;
}
const envs = res.stdout.replace(/\n+$/g, "").split(/\r?\n/);
for (const envName of envs) {
extractSecret(envName, shouldExportEnv);
validateSecretRefs(envs);
const host = process.env[envConnectHost];
const token = process.env[envConnectToken];
if (!host || !token) {
throw new Error(authErr);
}
// Authenticate with the Connect SDK
let client;
try {
client = OnePasswordConnect({
serverURL: host,
token,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Connect authentication failed: ${message}`);
}
for (const envName of envs) {
const ref = process.env[envName];
if (!ref) {
continue;
}
// Parse the op ref and get the item from the Connect SDK
const parsed = parseOpRef(ref);
const item = await client.getItem(parsed.vault, parsed.item);
// Get the secret value from the item as Connect returns a full item object
const secretValue = await getSecretFromConnectItem(client, item, parsed);
setResolvedSecret(envName, secretValue, shouldExportEnv);
}
if (shouldExportEnv) {
core.exportVariable(envManagedVariables, envs.join());
}