@gtrevorrow/oci-token-exchange
Version:
OCI Usder Principal Token Exchange for GitHub Actions, GitLab CI, and Bitbucket Pipelines
419 lines (418 loc) • 19.3 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TokenExchangeError = void 0;
exports.tokenExchangeJwtToUpst = tokenExchangeJwtToUpst;
exports.configureOciCli = configureOciCli;
exports.main = main;
/**
* Copyright (c) 2021, 2025 Oracle and/or its affiliates.
* Licensed under the Universal Permissive License v1.0 as shown at https://oss.oracle.com/licenses/upl.
*/
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const crypto_1 = __importDefault(require("crypto"));
const axios_1 = __importDefault(require("axios"));
const github_1 = require("./platforms/github");
const cli_1 = require("./platforms/cli");
const types_1 = require("./types");
Object.defineProperty(exports, "TokenExchangeError", { enumerable: true, get: function () { return types_1.TokenExchangeError; } });
const PLATFORM_CONFIGS = {
github: { audience: 'https://cloud.oracle.com' },
gitlab: { tokenEnvVar: 'CI_JOB_JWT_V2', audience: 'https://cloud.oracle.com' },
bitbucket: { tokenEnvVar: 'BITBUCKET_STEP_OIDC_TOKEN', audience: 'https://cloud.oracle.com' },
local: { tokenEnvVar: 'LOCAL_OIDC_TOKEN', audience: 'https://cloud.oracle.com' }
};
// Create platform instance based on environment
function createPlatform(platformType) {
const config = PLATFORM_CONFIGS[platformType];
if (!config) {
throw new Error(`Unsupported platform: ${platformType}`);
}
return platformType === 'github' ? new github_1.GitHubPlatform() : new cli_1.CLIPlatform(config);
}
// Generate RSA key pair
const { publicKey, privateKey } = crypto_1.default.generateKeyPairSync('rsa', {
modulusLength: 2048
});
async function delay(count) {
return new Promise(resolve => setTimeout(resolve, 1000 * count));
}
// Encode public key in a format the OCI token exchange endpoint expects
function encodePublicKeyToBase64() {
return publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
}
// Calculate the fingerprint of the OCI API public key
function calcFingerprint(publicKey) {
const publicKeyData = publicKey.export({ type: 'spki', format: 'der' });
const hash = crypto_1.default.createHash('MD5');
hash.update(publicKeyData);
return hash.digest('hex').replace(/(.{2})/g, '$1:').slice(0, -1);
}
// Function to validate URLs
function isValidUrl(url) {
try {
new URL(url);
return true;
}
catch (_) {
return false;
}
}
/**
* Performs a basic structural validation of a JWT and logs debug information.
* @param platform The platform instance for logging.
* @param token The JWT token string.
*/
// function validateAndLogJwtStructure(platform: Platform, token: string): void {
// if (!platform.isDebug()) {
// return; // Only run if debug mode is enabled
// }
//
// try {
// const parts = token.split('.');
// // Check if the token has the standard 3-part JWT structure
// if (parts.length === 3) {
// // Try to parse the token segments to validate it's a proper(ish) JWT
// // Note: This is a very basic check and does not guarantee the token's validity
// const header = JSON.parse(Buffer.from(parts[0], 'base64').toString());
// const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// platform.logger.debug(`JWT appears structured as expected (3 parts). Issuer: ${payload.iss || 'unknown'}, kid: ${header.kid || 'unknown'}`);
// } else {
// // If not 3 parts, log a warning. It might be an opaque token or malformed.
// platform.logger.debug(' OIDC token does not have the standard 3-part JWT structure.');
// }
// } catch (error) {
// platform.logger.warning(`Error during basic JWT structure check: ${error instanceof Error ? error.message : 'Unknown error'}`);
// // Continue, as the token might still be valid for exchange even if parsing failed here
// }
// }
// Function to exchange JWT for OCI UPST token
async function tokenExchangeJwtToUpst(platform, { tokenExchangeURL, clientCred, ociPublicKey, subjectToken, retryCount, currentAttempt = 0 }) {
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${clientCred}`
};
// Perform basic validation and logging if debug is enabled
// if (subjectToken) {
// validateAndLogJwtStructure(platform, subjectToken);
// }
const data = {
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
'requested_token_type': 'urn:oci:token-type:oci-upst',
'public_key': ociPublicKey,
'subject_token': subjectToken,
'subject_token_type': 'jwt'
};
// Note that this will log potentially sensitive information but will leave it up to the user to decide if they want to enable debug logging with this risk
platform.logger.debug('Token Exchange Request Data: ' + JSON.stringify(data));
try {
const response = await axios_1.default.post(tokenExchangeURL, data, { headers });
platform.logger.debug('Token Exchange Response: ' + JSON.stringify(response.data));
return response.data; // auto wrapped in a Promise
}
catch (error) {
const attemptCounter = currentAttempt ? currentAttempt : 0;
if (retryCount > 0 && retryCount >= attemptCounter) {
platform.logger.warning(`Token exchange failed, retrying ... (${retryCount - attemptCounter - 1} retries left)`);
await delay(attemptCounter + 1);
return tokenExchangeJwtToUpst(platform, {
tokenExchangeURL,
clientCred,
ociPublicKey,
subjectToken: subjectToken,
retryCount,
currentAttempt: attemptCounter + 1
});
}
else {
platform.logger.error('Failed to exchange JWT for UPST after multiple attempts');
if (error instanceof Error) {
throw new types_1.TokenExchangeError(`Token exchange failed: ${error.message}`, error);
}
else {
throw new types_1.TokenExchangeError('Token exchange failed with an unknown error');
}
}
}
}
/**
* Merge existing OCI config content by removing old profile section
* and appending a new profile block.
*/
function mergeOciConfig(existingRaw, profileName, profileObject) {
const lines = existingRaw.split('\n');
const filtered = [];
let inTargetSectionToRemove = false; // True if current lines are part of a section to be removed
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('[')) { // This line is a section header
if (trimmedLine === `[${profileName}]`) {
inTargetSectionToRemove = true; // This section matches the target, so we'll skip its lines
}
else {
inTargetSectionToRemove = false; // This is a different section, stop skipping
filtered.push(line); // Add this (different) section header line
}
}
else { // This line is not a section header (it's content or a comment or blank)
if (!inTargetSectionToRemove) {
// If not currently in a target section to remove, add the line,
// but only if it's not blank (to maintain original behavior for other sections).
if (trimmedLine !== '') {
filtered.push(line);
}
}
// If inTargetSectionToRemove is true, we do nothing (skip the line)
}
}
const merged = filtered.length ? filtered.join('\n') + '\n' : '';
const newSection = `[${profileName}]\n` +
Object.entries(profileObject)
.map(([k, v]) => `${k}=${v}`)
.join('\n') +
'\n';
return merged + newSection;
}
/**
* Write data to a file and optionally set file permissions.
*/
async function writeAndChmod(filePath, data, perms) {
await fs.writeFile(filePath, data);
if (perms) {
await fs.chmod(filePath, perms);
}
}
async function configureOciCli(platform, config) {
try {
const home = config.ociHome || process.env.HOME || '';
if (!home) {
throw new types_1.TokenExchangeError('HOME environment variable is not defined');
}
// Sanitize file paths to prevent path injection
const ociConfigDir = path.resolve(path.join(home, '.oci'));
const ociConfigFile = path.resolve(path.join(ociConfigDir, 'config'));
// Create a subfolder per profile to store keys and token
const profileName = config.ociProfile || 'DEFAULT';
// Ensure required OCI parameters are provided
if (!config.ociTenancy) {
throw new types_1.TokenExchangeError('OCI tenancy is not defined');
}
if (!config.ociRegion) {
throw new types_1.TokenExchangeError('OCI region is not defined');
}
// Create a subfolder per profile to store keys and token
const profileDir = path.resolve(path.join(ociConfigDir, profileName));
const ociPrivateKeyFile = path.resolve(path.join(profileDir, 'private_key.pem'));
const ociPublicKeyFile = path.resolve(path.join(profileDir, 'public_key.pem'));
const upstTokenFile = path.resolve(path.join(profileDir, 'session'));
platform.logger.debug(`OCI Config Dir: ${ociConfigDir}`);
// Prepare profile object for INI
const profileObject = {
user: 'not used',
fingerprint: config.ociFingerprint,
key_file: ociPrivateKeyFile,
tenancy: config.ociTenancy,
region: config.ociRegion,
security_token_file: upstTokenFile
};
platform.logger.debug(`Preparing OCI config for profile [${profileName}]`);
try {
await fs.mkdir(ociConfigDir, { recursive: true });
// Also ensure directory for this profile exists
await fs.mkdir(profileDir, { recursive: true });
}
catch (error) {
throw new types_1.TokenExchangeError('Failed to create OCI Config folder', error);
}
// Export and validate keys first
const privateKeyPem = config.privateKey.export({ type: 'pkcs1', format: 'pem' });
const publicKeyPem = config.publicKey.export({ type: 'spki', format: 'pem' });
if (!privateKeyPem || typeof privateKeyPem !== 'string') {
throw new Error('Private key export failed or invalid type');
}
if (!publicKeyPem || typeof publicKeyPem !== 'string') {
throw new Error('Public key export failed or invalid type');
}
if (!config.upstToken || typeof config.upstToken !== 'string') {
throw new Error('Session token is undefined or invalid type');
}
if (!profileObject || typeof profileObject !== 'object') {
throw new Error('OCI config is undefined or invalid type');
}
platform.logger.debug('Validated all file contents before writing');
// Build and write all files using helpers
try {
const existingRaw = await fs.readFile(ociConfigFile, 'utf-8').catch(() => '');
const finalContent = mergeOciConfig(existingRaw, profileName, profileObject);
// Write config with secure permissions
await writeAndChmod(ociConfigFile, finalContent, '600');
platform.logger.debug(`Set permissions 600 on OCI config file ${ociConfigFile}`);
// Write keys and token
await writeAndChmod(ociPrivateKeyFile, privateKeyPem, '600');
await writeAndChmod(ociPublicKeyFile, publicKeyPem);
await writeAndChmod(upstTokenFile, config.upstToken, '600');
}
catch (err) {
throw new types_1.TokenExchangeError('Failed to write OCI configuration files', err instanceof Error ? err : undefined);
}
}
catch (error) {
platform.setFailed(`Failed to configure OCI CLI: ${error}`);
throw error;
}
}
// Update debugPrintJWTToken to properly handle different token formats
function debugPrintJWTToken(platform, token) {
if (platform.isDebug()) {
platform.logger.debug(`JWT Token received (length: ${token.length} characters)`);
try {
const tokenParts = token.split('.');
if (tokenParts.length !== 3) {
platform.logger.debug(`Warning: JWT token does not have the expected format (header.payload.signature)`);
return;
}
// Only decode and print the header and selected parts of payload, not the full token
const headerStr = Buffer.from(tokenParts[0], 'base64').toString('utf8');
let header;
try {
header = JSON.parse(headerStr);
platform.logger.debug(`JWT Header: ${JSON.stringify(header)}`);
}
catch (e) {
platform.logger.debug(`Failed to parse JWT header: ${headerStr}`);
}
// Parse payload but only log safe information
try {
const payloadStr = Buffer.from(tokenParts[1], 'base64').toString('utf8');
const payload = JSON.parse(payloadStr);
const safePayload = {
iss: payload.iss,
aud: payload.aud,
exp: payload.exp,
iat: payload.iat,
sub: payload.sub ? `${payload.sub.substring(0, 10)}...` : undefined,
// Include timestamp information for troubleshooting token expiry issues
expires_at: payload.exp ? new Date(payload.exp * 1000).toISOString() : undefined,
issued_at: payload.iat ? new Date(payload.iat * 1000).toISOString() : undefined
};
platform.logger.debug(`JWT Payload (safe parts): ${JSON.stringify(safePayload)}`);
}
catch (e) {
platform.logger.debug(`Failed to parse JWT payload: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
platform.logger.debug(`JWT Signature present: ${tokenParts[2].length > 0 ? 'Yes' : 'No'}`);
}
catch (error) {
platform.logger.debug(`Error parsing JWT token: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
// Main function now creates a local platform instance and passes it to subfunctions
async function main() {
const platformType = process.env.PLATFORM || 'github';
if (!PLATFORM_CONFIGS[platformType]) {
throw new Error(`Unsupported platform: ${platformType}`);
}
const platform = createPlatform(platformType);
try {
const config = ['oidc_client_identifier', 'domain_base_url', 'oci_tenancy', 'oci_region', 'oci_home', 'oci_profile']
.reduce((acc, input) => ({
...acc,
[input]: platform.getInput(input, input !== 'oci_home' && input !== 'oci_profile')
}), {});
const retryCount = parseInt(platform.getInput('retry_count', false) || '0');
if (isNaN(retryCount) || retryCount < 0) {
throw new Error('retry_count must be a non-negative number');
}
// Validate the tokenExchangeURL
if (!isValidUrl(`${config.domain_base_url}/oauth2/v1/token`)) {
throw new Error('Invalid domain_base_url provided');
}
const idToken = await platform.getOIDCToken(PLATFORM_CONFIGS[platformType].audience);
platform.logger.debug(`Token obtained from ${platformType}`);
debugPrintJWTToken(platform, idToken);
// Calculate the fingerprint of the public key
const ociFingerprint = calcFingerprint(publicKey);
// Get the B64 encoded public key DER
const publicKeyB64 = encodePublicKeyToBase64();
platform.logger.debug(`Public Key B64: ${publicKeyB64}`);
//Exchange platform OIDC token for OCI UPST
const upstToken = await tokenExchangeJwtToUpst(platform, {
tokenExchangeURL: `${config.domain_base_url}/oauth2/v1/token`,
clientCred: Buffer.from(config.oidc_client_identifier).toString('base64'),
ociPublicKey: publicKeyB64,
subjectToken: idToken,
retryCount
});
platform.logger.info(`OCI issued a Session Token `);
// Resolve OCI home and profile, falling back to environment or defaults
const resolvedOciHome = config.oci_home || process.env.OCI_HOME;
const resolvedOciProfile = config.oci_profile || process.env.OCI_PROFILE || 'DEFAULT';
const ociConfig = {
ociHome: resolvedOciHome,
ociProfile: resolvedOciProfile,
privateKey,
publicKey,
upstToken: upstToken.token,
ociFingerprint,
ociTenancy: config.oci_tenancy,
ociRegion: config.oci_region
};
await configureOciCli(platform, ociConfig);
platform.logger.info(`OCI CLI has been configured to use the session token`);
// Add success output
platform.setOutput('configured', 'true');
// Error Handling
}
catch (error) {
if (error instanceof types_1.TokenExchangeError) {
platform.setFailed(`Token exchange failed: ${error.message}`);
if (error.cause) {
platform.logger.debug(`Cause: ${error.cause}`);
}
}
else {
platform.setFailed(`Action failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
throw error;
}
}
if (require.main === module) {
main();
}