Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <targets...>', `Target architectures. Valid targets: ${ValidTargets}`)
.option('--ignore-other-target-folders', `Ignore other target folders. Valid only when --target <target> is provided.`)
.option('--readme-path <path>', 'Path to README file (defaults to README.md)')
Expand Down Expand Up @@ -259,6 +265,7 @@ module.exports = function (argv: string[]): void {
{
pat,
azureCredential,
oidc,
target,
ignoreOtherTargetFolders,
readmePath,
Expand Down Expand Up @@ -296,8 +303,9 @@ module.exports = function (argv: string[]): void {
) =>
main(
publish({
pat,
pat: oidc ? undefined : pat,
azureCredential,
oidc,
version,
targets: target,
ignoreOtherTargetFolders,
Expand Down
192 changes: 192 additions & 0 deletions src/oidc.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>;
readonly body?: string;
}

export interface IOIDCHttpResponse {
readonly statusCode: number;
readonly statusMessage: string;
readBody(): Promise<string>;
}

export type OIDCHttpRequestHandler = (url: string, request: IOIDCHttpRequest) => Promise<IOIDCHttpResponse>;

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<string>;
}

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<string> {
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<string> {
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<string> {
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<IOIDCHttpResponse> {
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<unknown> {
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<string, unknown> {
return typeof value === 'object' && value !== null;
}
35 changes: 33 additions & 2 deletions src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -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[];
Expand All @@ -91,7 +96,11 @@ export interface IPublishOptions {
readonly signTool?: string;
}

type AuthenticationOptions = Pick<IPublishOptions, 'pat' | 'azureCredential' | 'oidc'>;

export async function publish(options: IPublishOptions = {}): Promise<any> {
validateAuthenticationOptions(options);

if (options.packagePath) {
if (options.version) {
throw new Error(`Both options not supported simultaneously: 'packagePath' and 'version'.`);
Expand Down Expand Up @@ -183,6 +192,8 @@ export async function publish(options: IPublishOptions = {}): Promise<any> {
export interface IInternalPublishOptions {
readonly target?: string;
readonly pat?: string;
readonly azureCredential?: boolean;
readonly oidc?: boolean;
readonly allowProposedApi?: boolean;
readonly noVerify?: boolean;
readonly allowProposedApis?: string[];
Expand Down Expand Up @@ -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<string> {
export async function getPAT(publisher: string, options: AuthenticationOptions): Promise<string> {
validateAuthenticationOptions(options);

if (options.oidc) {
return await getOIDCCredential(publisher);
}

if (options.pat) {
return options.pat;
}
Expand All @@ -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.`);
}
}
Loading
Loading