import * as core from "@actions/core"; import sshpk from "sshpk"; 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, envConnectHost, envConnectToken, envServiceAccountToken, envManagedVariables, } from "./constants"; // #region Op ref parsing interface ParsedOpRef { vault: string; item: string; section: string | undefined; field: string; queryParams: string | undefined; } export 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://// or op:////
/. 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 the field; it may include a query string (e.g. "private key?ssh-format=openssh") const lastSegment = segments[segments.length - 1] ?? ""; const [fieldPart, queryPart] = lastSegment.split("?"); const field = fieldPart ?? ""; if (!field) { throw new Error(`Invalid op reference: field is required`); } const queryParams = queryPart ?? undefined; // 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, queryParams, }; }; // #endregion // #region Connect item resolution const getSecretFromConnectItem = async ( client: OPConnect, item: FullItem, parsed: ParsedOpRef, ): Promise => { const sectionIds = parsed.section ? findSectionIdsByQuery(item.sections, parsed.section) : []; const { fieldValue, fileId } = findMatchingFieldAndFile( item, parsed.field, sectionIds, ); if (fieldValue !== undefined) { return fieldValue; } if (fileId) { return getFileContentWithRetry(client, parsed.vault, parsed.item, fileId); } 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 getFileContentWithRetry = async ( client: OPConnect, vaultId: string, itemId: string, fileId: string, ): Promise => { const maxAttempts = 3; const retryDelayMs = 2000; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await client.getFileContent(vaultId, itemId, fileId); } catch (err) { // Retry on 503 errors as this can happen on multiple secret fetches const is503 = err !== null && typeof err === "object" && (err as Record).statusCode === 503; if (is503 && attempt < maxAttempts) { await new Promise((r) => setTimeout(r, retryDelayMs)); continue; } throw err; } } return ""; }; export 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( `Item has no sections; cannot resolve section "${sectionQuery}"`, ); } const ids = sections .filter((s) => s.id === sectionQuery || s.label === sectionQuery) .flatMap((s) => (s.id ? [s.id] : [])); // If no sections were found with the given query throw an error if (ids.length === 0) { throw new Error( `No section matching "${sectionQuery}" found in specified item`, ); } return ids; }; export const findMatchingFieldAndFile = ( item: FullItem, fieldOrFileQuery: string, sectionIds: string[], ): { fieldValue?: string; fileId?: string } => { // Get the fields/files from the item and check if the ref has a section filter const fields = item.fields ?? []; const files = item.files ?? []; const sectionFilter = sectionIds.length > 0; const fieldMatchesQuery = (f: (typeof fields)[0]) => f.id === fieldOrFileQuery || f.label === fieldOrFileQuery; const fileMatchesQuery = (f: (typeof files)[0]) => f.id === fieldOrFileQuery || f.name === fieldOrFileQuery; let matchedField: (typeof fields)[0] | undefined; let matchedFile: (typeof files)[0] | undefined; if (sectionFilter) { // If the ref has a section filter only accept matches inside the referenced sections const matchingFields = fields.filter((f) => { const sectionId = f.section?.id; const inRefSections = sectionId !== null && sectionId !== undefined && sectionIds.includes(sectionId); return fieldMatchesQuery(f) && inRefSections; }); matchedField = findSingleMatch(matchingFields); const matchingFiles = files.filter((f) => { const sectionId = f.section?.id; const inRefSections = sectionId !== null && sectionId !== undefined && sectionIds.includes(sectionId); return fileMatchesQuery(f) && inRefSections; }); matchedFile = findSingleMatch(matchingFiles); } else { // If the ref has no section filter search for matches with no section const matchingFields = fields.filter((f) => { const hasNoSection = f.section?.id === null || f.section?.id === undefined; return fieldMatchesQuery(f) && hasNoSection; }); matchedField = findSingleMatch(matchingFields); // If no matches were found with no section, search for matches in any section if (!matchedField) { const matchingFieldsInAnySection = fields.filter(fieldMatchesQuery); matchedField = findSingleMatch(matchingFieldsInAnySection); } const matchingFiles = files.filter((f) => { const hasNoSection = f.section?.id === null || f.section?.id === undefined; return fileMatchesQuery(f) && hasNoSection; }); matchedFile = findSingleMatch(matchingFiles); if (!matchedFile) { const matchingFilesInAnySection = files.filter(fileMatchesQuery); matchedFile = findSingleMatch(matchingFilesInAnySection); } } if (matchedField && matchedFile) { throw new Error( `Both a field and a file match "${fieldOrFileQuery}". Rename one or use the ID in your op:// reference.`, ); } 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) { return { fileId: matchedFile.id }; } return {}; }; const findSingleMatch = (matches: T[]): T | undefined => { if (matches.length > 1) { throw new Error( "Multiple matches found. Rename one or use an ID in your op:// reference.", ); } return matches[0]; }; const createConnectClient = (host: string, token: string): OPConnect => { try { return OnePasswordConnect({ // eslint-disable-next-line @typescript-eslint/naming-convention serverURL: host, token, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Connect authentication failed: ${message}`); } }; // eslint-disable-next-line @typescript-eslint/naming-convention const toOpenSSH = (value: string, _queryParams: string | undefined): string => { try { const key = sshpk.parsePrivateKey(value, "auto"); return key.toString("openssh"); } catch { core.warning( `Failed to parse private key to OpenSSH, returning original value`, ); return value; } }; // #endregion // #region Shared helpers and auth export const getEnvVarNamesWithSecretRefs = (): string[] => Object.keys(process.env).filter( (key) => typeof process.env[key] === "string" && process.env[key]?.startsWith("op://"), ); const validateSecretRefs = (envNames: string[]): void => { const invalid: { name: string; message: string }[] = []; for (const envName of envNames) { const ref = process.env[envName]; if (!ref) { continue; } try { Secrets.validateSecretReference(ref); } catch (err) { const message = err instanceof Error ? err.message : String(err); invalid.push({ name: envName, message }); } } // Throw an error if any secret references are invalid if (invalid.length > 0) { const details = invalid .map(({ name, message }) => `${name}: ${message}`) .join("; "); throw new Error(`Invalid secret reference(s): ${details}`); } }; const setResolvedSecret = ( envName: string, secretValue: string, shouldExportEnv: boolean, ): void => { core.info(`Populating variable: ${envName}`); if (shouldExportEnv) { core.exportVariable(envName, secretValue); } else { core.setOutput(envName, secretValue); } if (secretValue) { core.setSecret(secretValue); } }; export const validateAuth = (): void => { const isConnect = process.env[envConnectHost] && process.env[envConnectToken]; const isServiceAccount = process.env[envServiceAccountToken]; if (isConnect && isServiceAccount) { core.warning( "WARNING: Both service account and Connect credentials are provided. Connect credentials will take priority.", ); } if (!isConnect && !isServiceAccount) { throw new Error(authErr); } const authType = isConnect ? "Connect" : "Service account"; core.info(`Authenticated with ${authType}.`); }; export const extractSecret = ( envName: string, shouldExportEnv: boolean, ): void => { const ref = process.env[envName]; if (!ref) { return; } const secretValue = read.parse(ref); if (secretValue === null || secretValue === undefined) { return; } setResolvedSecret(envName, secretValue, shouldExportEnv); }; export const unsetPrevious = (): void => { if (process.env[envManagedVariables]) { core.info("Unsetting previous values ..."); const managedEnvs = process.env[envManagedVariables].split(","); for (const envName of managedEnvs) { core.info(`Unsetting ${envName}`); core.exportVariable(envName, ""); } } }; const fetchVaultId = async ( client: OPConnect, vaultQuery: string, ref: string, vaultIdCache: Map, ): Promise => { // Check if the vault ID is already cached to avoid unnecessary API calls const cached = vaultIdCache.get(vaultQuery); if (cached !== undefined) { return cached; } const vault = await client.getVault(vaultQuery); if (!vault.id) { throw new Error( `Could not find valid vault "${vaultQuery}" for ref "${ref}"`, ); } vaultIdCache.set(vaultQuery, vault.id); return vault.id; }; // #endregion // #region Load secrets // Connect loads secrets via the Connect JS SDK const loadSecretsViaConnect = async ( shouldExportEnv: boolean, ): Promise => { const envs = getEnvVarNamesWithSecretRefs(); if (envs.length === 0) { return; } validateSecretRefs(envs); const host = process.env[envConnectHost]; const token = process.env[envConnectToken]; if (!host || !token) { throw new Error(authErr); } const client = createConnectClient(host, token); const vaultIdCache = new Map(); for (const envName of envs) { const ref = process.env[envName]; if (!ref) { continue; } try { // Parse the op ref and get the item from the Connect SDK const parsed = parseOpRef(ref); const vaultId = await fetchVaultId( client, parsed.vault, ref, vaultIdCache, ); const item = await client.getItem(vaultId, parsed.item); // Get the secret value from the item as Connect returns a full item object const secretValue = await getSecretFromConnectItem(client, item, parsed); let valueToSet = secretValue; if (parsed.queryParams?.includes("ssh-format=openssh")) { valueToSet = toOpenSSH(secretValue, parsed.queryParams); } setResolvedSecret(envName, valueToSet, shouldExportEnv); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed to load ref "${ref}": ${msg}`); } } if (shouldExportEnv) { core.exportVariable(envManagedVariables, envs.join()); } }; // Service Account loads secrets via the 1Password SDK const loadSecretsViaServiceAccount = async ( shouldExportEnv: boolean, ): Promise => { const envs = getEnvVarNamesWithSecretRefs(); if (envs.length === 0) { return; } validateSecretRefs(envs); const token = process.env[envServiceAccountToken]; if (!token) { throw new Error(authErr); } // Authenticate with the 1Password SDK let client; try { client = await createClient({ auth: token, integrationName: "1Password GitHub Action", integrationVersion: version, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Service account authentication failed: ${message}`); } for (const envName of envs) { const ref = process.env[envName]; if (!ref) { continue; } // Resolve the secret value using the 1Password SDK // and make it available either as step outputs or as environment variables const secretValue = await client.secrets.resolve(ref); setResolvedSecret(envName, secretValue, shouldExportEnv); } if (shouldExportEnv) { core.exportVariable(envManagedVariables, envs.join()); } }; export const loadSecrets = async (shouldExportEnv: boolean): Promise => { const isConnect = process.env[envConnectHost] && process.env[envConnectToken]; if (isConnect) { await loadSecretsViaConnect(shouldExportEnv); return; } await loadSecretsViaServiceAccount(shouldExportEnv); }; // #endregion