From 9db7c049cc618b3d4c4cab0c8f063dbc0a787b3c Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Thu, 23 Jul 2026 22:13:22 +0200 Subject: [PATCH] feat: support OIDC trusted publishing Add a provider-neutral OIDC credential flow with GitHub Actions as the first provider. Exchange workflow identity tokens for short-lived Marketplace credentials and route them through the existing publish path without PAT fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 25 ++++++ src/main.ts | 10 ++- src/oidc.ts | 192 ++++++++++++++++++++++++++++++++++++++++++ src/publish.ts | 35 +++++++- src/test/oidc.test.ts | 158 ++++++++++++++++++++++++++++++++++ 5 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 src/oidc.ts create mode 100644 src/test/oidc.test.ts diff --git a/README.md b/README.md index 817bec83..aa60ac84 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,31 @@ Or you can also set them in the `package.json`, so that you avoid having to rety } ``` +### Trusted publishing + +`vsce publish --oidc` publishes from GitHub Actions without storing a Personal Access Token. Configure a trusted +publishing policy for the repository and workflow on the Visual Studio Marketplace, then grant the workflow permission +to request an OIDC token: + +```yaml +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci + - run: npx @vscode/vsce publish --oidc +``` + +OIDC publishing requests a GitHub Actions token for the `marketplace.visualstudio.com` audience and exchanges it for a +short-lived Marketplace credential. It does not fall back to a PAT when token acquisition or exchange fails. + ## Development First clone this repository, then: diff --git a/src/main.ts b/src/main.ts index 68d14e3a..86f2a9d1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -208,6 +208,12 @@ module.exports = function (argv: string[]): void { process.env['VSCE_PAT'] ) .option('--azure-credential', 'Use Microsoft Entra ID for authentication') + .addOption( + new Option('--oidc', 'Use OpenID Connect trusted publishing for authentication').conflicts([ + 'pat', + 'azureCredential', + ]) + ) .option('-t, --target ', `Target architectures. Valid targets: ${ValidTargets}`) .option('--ignore-other-target-folders', `Ignore other target folders. Valid only when --target is provided.`) .option('--readme-path ', 'Path to README file (defaults to README.md)') @@ -259,6 +265,7 @@ module.exports = function (argv: string[]): void { { pat, azureCredential, + oidc, target, ignoreOtherTargetFolders, readmePath, @@ -296,8 +303,9 @@ module.exports = function (argv: string[]): void { ) => main( publish({ - pat, + pat: oidc ? undefined : pat, azureCredential, + oidc, version, targets: target, ignoreOtherTargetFolders, diff --git a/src/oidc.ts b/src/oidc.ts new file mode 100644 index 00000000..f1619296 --- /dev/null +++ b/src/oidc.ts @@ -0,0 +1,192 @@ +import { getMarketplaceUrl } from './util'; + +export const OIDC_AUDIENCE = 'marketplace.visualstudio.com'; + +export interface IOIDCHttpRequest { + readonly method: 'GET' | 'POST'; + readonly headers: Readonly>; + readonly body?: string; +} + +export interface IOIDCHttpResponse { + readonly statusCode: number; + readonly statusMessage: string; + readBody(): Promise; +} + +export type OIDCHttpRequestHandler = (url: string, request: IOIDCHttpRequest) => Promise; + +interface IOIDCTokenProviderContext { + readonly environment: NodeJS.ProcessEnv; + readonly request: OIDCHttpRequestHandler; +} + +interface IOIDCTokenProvider { + readonly name: string; + isAvailable(environment: NodeJS.ProcessEnv): boolean; + getToken(audience: string, context: IOIDCTokenProviderContext): Promise; +} + +class GitHubActionsOIDCTokenProvider implements IOIDCTokenProvider { + readonly name = 'GitHub Actions'; + + isAvailable(environment: NodeJS.ProcessEnv): boolean { + return environment['GITHUB_ACTIONS']?.toLowerCase() === 'true'; + } + + async getToken(audience: string, { environment, request }: IOIDCTokenProviderContext): Promise { + const requestUrl = environment['ACTIONS_ID_TOKEN_REQUEST_URL']; + const requestToken = environment['ACTIONS_ID_TOKEN_REQUEST_TOKEN']; + + if (!requestUrl || !requestToken) { + throw new Error( + 'GitHub Actions did not provide an OIDC token request URL and token. Add `permissions: id-token: write` to the workflow or job.' + ); + } + + let tokenUrl: URL; + try { + tokenUrl = new URL(requestUrl); + } catch { + throw new Error('GitHub Actions provided an invalid ACTIONS_ID_TOKEN_REQUEST_URL.'); + } + tokenUrl.searchParams.set('audience', audience); + + const result = await requestJSON('GitHub Actions OIDC token request', tokenUrl.toString(), request, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${requestToken}`, + }, + }); + + if (!isRecord(result) || typeof result.value !== 'string' || !result.value) { + throw new Error('GitHub Actions OIDC token request returned an invalid response without a token.'); + } + + return result.value; + } +} + +const oidcTokenProviders: readonly IOIDCTokenProvider[] = [new GitHubActionsOIDCTokenProvider()]; + +export interface IGetOIDCCredentialOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly marketplaceUrl?: string; + readonly request?: OIDCHttpRequestHandler; +} + +export async function getOIDCCredential( + publisherName: string, + options: IGetOIDCCredentialOptions = {} +): Promise { + const environment = options.environment ?? process.env; + const request = options.request ?? defaultRequest; + const provider = oidcTokenProviders.find(candidate => candidate.isAvailable(environment)); + + if (!provider) { + throw new Error('No supported OIDC provider was detected. OIDC publishing currently supports GitHub Actions only.'); + } + + const oidcToken = await provider.getToken(OIDC_AUDIENCE, { environment, request }); + return await exchangeOIDCToken(publisherName, oidcToken, options.marketplaceUrl ?? getMarketplaceUrl(), request); +} + +async function exchangeOIDCToken( + publisherName: string, + oidcToken: string, + marketplaceUrl: string, + request: OIDCHttpRequestHandler +): Promise { + const result = await requestJSON( + 'Marketplace OIDC token exchange', + `${marketplaceUrl.replace(/\/$/, '')}/_apis/gallery/token`, + request, + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${oidcToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'vsce', + }, + body: JSON.stringify({ publisherName }), + } + ); + + if (!isRecord(result) || typeof result.credential !== 'string' || !result.credential) { + throw new Error('Marketplace OIDC token exchange returned an invalid response without a credential.'); + } + + return result.credential; +} + +async function defaultRequest(url: string, request: IOIDCHttpRequest): Promise { + const response = await fetch(url, request); + return { + statusCode: response.status, + statusMessage: response.statusText, + readBody: () => response.text(), + }; +} + +async function requestJSON( + operation: string, + url: string, + request: OIDCHttpRequestHandler, + init: IOIDCHttpRequest +): Promise { + let response: IOIDCHttpResponse; + try { + response = await request(url, init); + } catch (error) { + throw new Error(`${operation} failed: ${getErrorMessage(error)}`); + } + + let body: string; + try { + body = await response.readBody(); + } catch (error) { + throw new Error(`${operation} failed while reading the response: ${getErrorMessage(error)}`); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + const status = `${response.statusCode}${response.statusMessage ? ` ${response.statusMessage}` : ''}`; + throw new Error(`${operation} failed with ${status}${getResponseDetails(body)}`); + } + + try { + return JSON.parse(body); + } catch { + throw new Error(`${operation} returned an invalid JSON response.`); + } +} + +function getResponseDetails(body: string): string { + const trimmedBody = body.trim(); + if (!trimmedBody) { + return '.'; + } + + try { + const parsed: unknown = JSON.parse(trimmedBody); + if (isRecord(parsed)) { + const message = parsed.message ?? parsed.error_description ?? parsed.error; + if (typeof message === 'string' && message) { + return `: ${message}`; + } + } + } catch { + // Use the plain response body below. + } + + return `: ${trimmedBody.slice(0, 500)}`; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/publish.ts b/src/publish.ts index 7475afcb..825c8aa9 100644 --- a/src/publish.ts +++ b/src/publish.ts @@ -4,7 +4,7 @@ import * as semver from 'semver'; import { ExtensionQueryFlags, PublishedExtension } from 'azure-devops-node-api/interfaces/GalleryInterfaces'; import { pack, readManifest, versionBump, prepublish, signPackage, createSignatureArchive } from './package'; import * as tmp from 'tmp'; -import { IVerifyPatOptions, getPublisher } from './store'; +import { getPublisher } from './store'; import { getGalleryAPI, read, getPublishedUrl, log, getHubUrl, patchOptionsWithManifest } from './util'; import { ManifestPackage, ManifestPublish } from './manifest'; import { readVSIXPackage } from './zip'; @@ -14,6 +14,7 @@ import FormData from 'form-data'; import { basename } from 'path'; import { IterableBackoff, handleWhen, retry } from 'cockatiel'; import { getAzureCredentialAccessToken } from './auth'; +import { getOIDCCredential } from './oidc'; const tmpName = promisify(tmp.tmpName); @@ -70,6 +71,10 @@ export interface IPublishOptions { */ readonly pat?: string; readonly azureCredential?: boolean; + /** + * Use OpenID Connect trusted publishing to acquire a short-lived Marketplace credential. + */ + readonly oidc?: boolean; readonly allowProposedApi?: boolean; readonly noVerify?: boolean; readonly allowProposedApis?: string[]; @@ -91,7 +96,11 @@ export interface IPublishOptions { readonly signTool?: string; } +type AuthenticationOptions = Pick; + export async function publish(options: IPublishOptions = {}): Promise { + validateAuthenticationOptions(options); + if (options.packagePath) { if (options.version) { throw new Error(`Both options not supported simultaneously: 'packagePath' and 'version'.`); @@ -183,6 +192,8 @@ export async function publish(options: IPublishOptions = {}): Promise { export interface IInternalPublishOptions { readonly target?: string; readonly pat?: string; + readonly azureCredential?: boolean; + readonly oidc?: boolean; readonly allowProposedApi?: boolean; readonly noVerify?: boolean; readonly allowProposedApis?: string[]; @@ -354,7 +365,13 @@ function validateManifestForPublishing(manifest: ManifestPackage, options: IInte return { ...manifest, publisher: validatePublisher(manifest.publisher) }; } -export async function getPAT(publisher: string, options: IPublishOptions | IUnpublishOptions | IVerifyPatOptions): Promise { +export async function getPAT(publisher: string, options: AuthenticationOptions): Promise { + validateAuthenticationOptions(options); + + if (options.oidc) { + return await getOIDCCredential(publisher); + } + if (options.pat) { return options.pat; } @@ -365,3 +382,17 @@ export async function getPAT(publisher: string, options: IPublishOptions | IUnpu return (await getPublisher(publisher)).pat; } + +function validateAuthenticationOptions(options: AuthenticationOptions): void { + if (!options.oidc) { + return; + } + + if (options.pat) { + throw new Error(`The '--oidc' and '--pat' options cannot be used together.`); + } + + if (options.azureCredential) { + throw new Error(`The '--oidc' and '--azure-credential' options cannot be used together.`); + } +} diff --git a/src/test/oidc.test.ts b/src/test/oidc.test.ts new file mode 100644 index 00000000..2cbd8f8b --- /dev/null +++ b/src/test/oidc.test.ts @@ -0,0 +1,158 @@ +import * as assert from 'assert'; +import { + getOIDCCredential, + IOIDCHttpRequest, + IOIDCHttpResponse, + OIDC_AUDIENCE, + OIDCHttpRequestHandler, +} from '../oidc'; +import { getPAT } from '../publish'; + +interface RecordedRequest { + readonly url: string; + readonly request: IOIDCHttpRequest; +} + +describe('OIDC trusted publishing', () => { + it('requests a GitHub Actions token and exchanges it for a Marketplace credential', async () => { + const requests: RecordedRequest[] = []; + const request = createRequestHandler( + requests, + response({ value: 'github-oidc-token' }), + response({ + credential: 'marketplace-session-token', + expires: '2026-05-22T16:30:00Z', + publisherName: 'my-publisher', + }) + ); + + const credential = await getOIDCCredential('my-publisher', { + environment: { + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://token.actions.example/id-token?api-version=1', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'github-runtime-token', + }, + marketplaceUrl: 'https://marketplace.example', + request, + }); + + assert.strictEqual(credential, 'marketplace-session-token'); + assert.strictEqual(requests.length, 2); + + const githubRequestUrl = new URL(requests[0].url); + assert.strictEqual(githubRequestUrl.searchParams.get('api-version'), '1'); + assert.strictEqual(githubRequestUrl.searchParams.get('audience'), OIDC_AUDIENCE); + assert.deepStrictEqual(requests[0].request, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: 'Bearer github-runtime-token', + }, + }); + + assert.strictEqual(requests[1].url, 'https://marketplace.example/_apis/gallery/token'); + assert.deepStrictEqual(requests[1].request, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: 'Bearer github-oidc-token', + 'Content-Type': 'application/json', + 'User-Agent': 'vsce', + }, + body: JSON.stringify({ publisherName: 'my-publisher' }), + }); + }); + + it('explains how to enable GitHub Actions OIDC token requests', async () => { + await assert.rejects( + getOIDCCredential('my-publisher', { + environment: { GITHUB_ACTIONS: 'true' }, + request: unexpectedRequest, + }), + /id-token: write/ + ); + }); + + it('rejects unsupported environments', async () => { + await assert.rejects( + getOIDCCredential('my-publisher', { + environment: {}, + request: unexpectedRequest, + }), + /OIDC publishing currently supports GitHub Actions only/ + ); + }); + + it('surfaces Marketplace token exchange errors without falling back', async () => { + const request = createRequestHandler( + [], + response({ value: 'github-oidc-token' }), + response({ message: 'No matching trusted publishing policy.' }, 401, 'Unauthorized') + ); + + await assert.rejects( + getOIDCCredential('my-publisher', { + environment: { + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://token.actions.example/id-token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'github-runtime-token', + }, + marketplaceUrl: 'https://marketplace.example/', + request, + }), + /Marketplace OIDC token exchange failed with 401 Unauthorized: No matching trusted publishing policy/ + ); + }); + + it('rejects malformed successful exchange responses', async () => { + const request = createRequestHandler( + [], + response({ value: 'github-oidc-token' }), + response({ expires: '2026-05-22T16:30:00Z' }) + ); + + await assert.rejects( + getOIDCCredential('my-publisher', { + environment: { + GITHUB_ACTIONS: 'true', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://token.actions.example/id-token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'github-runtime-token', + }, + request, + }), + /invalid response without a credential/ + ); + }); + + it('rejects OIDC combined with another authentication method', async () => { + await assert.rejects(getPAT('my-publisher', { oidc: true, pat: 'pat' }), /'--oidc' and '--pat'/); + await assert.rejects( + getPAT('my-publisher', { oidc: true, azureCredential: true }), + /'--oidc' and '--azure-credential'/ + ); + }); +}); + +function createRequestHandler( + requests: RecordedRequest[], + ...responses: IOIDCHttpResponse[] +): OIDCHttpRequestHandler { + return async (url, request) => { + requests.push({ url, request }); + const nextResponse = responses.shift(); + assert.ok(nextResponse, 'Unexpected HTTP request'); + return nextResponse; + }; +} + +function response(body: unknown, statusCode = 200, statusMessage = 'OK'): IOIDCHttpResponse { + return { + statusCode, + statusMessage, + readBody: async () => JSON.stringify(body), + }; +} + +async function unexpectedRequest(): Promise { + throw new Error('Unexpected HTTP request'); +}