Skip to content

Trusted Publishing support#1967

Open
cstamas wants to merge 7 commits into
eclipse-openvsx:mainfrom
cstamas:trusted-publishing
Open

Trusted Publishing support#1967
cstamas wants to merge 7 commits into
eclipse-openvsx:mainfrom
cstamas:trusted-publishing

Conversation

@cstamas

@cstamas cstamas commented Jul 14, 2026

Copy link
Copy Markdown
Member

The flow

  1. A namespace owner registers a trusted publisher via POST /user/namespace/{ns}/trusted-publishing/create (body: provider, owner, repo, workflow, optional extension/environment). The provider resolves names to stable IDs (GitHub repos API, GitLab projects API) and the claims are stored in a new trusted_publisher table (JSONB column, migration V1_70). List and delete endpoints exist alongside it, all owner-only.
  2. CI exchanges its OIDC ID token via POST /api/-/trusted-publishing/token (CSRF-exempted like /api/-/publish). The manager picks the provider by iss, validates the JWT (signature, issuer, audience, forbidden headers), and matches claims against registrations.
  3. On match, it mints a short-lived PersonalAccessToken (default 15 min, ovsx.trusted-publishing.token-expiry) owned by the registering user, so the existing publish pipeline — permissions, auditing, expiry — is reused unchanged.

Key decisions baked in

  • Matching is anchored on stable numeric IDs (repository_id/repository_owner_id, project_id/namespace_id) to block resurrection attacks, plus the workflow path with the @ suffix stripped (any branch/tag can publish), plus environment only if pinned.
  • The OIDC decoder is built lazily — JwtDecoders.fromIssuerLocation() does network discovery, which previously ran in the constructor and would have made app startup depend on external issuers.
  • The tests for both providers added, also got offline matches() tests covering ref-independence, ID mismatch, and pinned environments.

Note: namespace-scoped tokens (the minted token has the registering user's full publish rights). This is server-side work only; no CLI or WebUI changes added.

Fixes #1534

@cstamas cstamas force-pushed the trusted-publishing branch from 5898f77 to 833149e Compare July 14, 2026 09:55
@netomi netomi self-requested a review July 14, 2026 10:04
@cstamas cstamas marked this pull request as ready for review July 14, 2026 10:04
CREATE TABLE IF NOT EXISTS public.trusted_publisher
(
id BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('trusted_publisher_seq'),
namespace BIGINT NOT NULL REFERENCES public.namespace(id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is there a relationship to the namespace but not the extension?

I am not sure why we even need the relationship to the namespace here, as an extension has a relationship to the namespace anyways.

also we should add cascade rules for the table in case the foreign keys gets deleted

* GitLab provider for <a href="https://gitlab.eclipse.org/">Eclipse GitLab Instance</a>.
*/
public class EclipseGitLabTrustedPublishingProvider extends GitLabTrustedPublishingProviderSupport {
public static final String PROVIDER_ID = "eclipse-gitlab";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want to make that completely configurable, not hard-coded in code if possible

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now there are 3 hardcoded providers, but yes, as you can see from their implementation, they are really just providerId, name (for human consumption), issuer URL.... so these could come from config as well.

import static java.util.Objects.requireNonNull;

@Component
public class TrustedPublishingManager {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we generally use the terminology xyzService for stuff like that and annotate with @service annotation

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the change.

}

// select provider based on "iss"
TrustedPublishingProviderSupport provider = providers.values().stream()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at which point do we validate that the JWT is actually really coming from the expected authority?

Here is some python code that does to seem to verify the identity:

https://github.com/rjw57/verify-oidc-identity

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first part of flow (see ref here): "Parse the JWT without verifying the claims or signature to extract the issuer claim (iss). Be sure that this parsed JWT is discarded and that the JWT isn’t marked as verified by the service at this stage.".

After provider was selected based on iss, the provider does actual validation, see decoder in TrustedPublishingProviderSupport class, that pre-fetches keys using issuer URL from "well know" URL built from issuer URL.

@cstamas cstamas force-pushed the trusted-publishing branch from f6a2f7e to fcb7a00 Compare July 15, 2026 07:30
cstamas added 2 commits July 15, 2026 12:14
How the finished flow works

1. A namespace owner registers a trusted publisher via `POST /user/namespace/{ns}/trusted-publishing/create` (body: provider, owner, repo, workflow, optional extension/environment). The provider resolves names to stable IDs (GitHub repos API, GitLab projects API) and the claims are stored in a new trusted_publisher table (JSONB column, migration V1_70). List and delete endpoints exist alongside it, all owner-only.
2. CI exchanges its OIDC ID token via POST /api/-/trusted-publishing/token (CSRF-exempted like /api/-/publish). The manager picks the provider by `iss`, validates the JWT (signature, issuer, audience, forbidden headers), and matches claims against registrations.
3. On match, it mints a short-lived `PersonalAccessToken` (default 15 min, `ovsx.trusted-publishing.token-expiry`) owned by the registering user, so the existing publish pipeline — permissions, auditing, expiry — is reused unchanged.

Key decisions baked in

- Matching is anchored on stable numeric IDs (repository_id/repository_owner_id, project_id/namespace_id) to block resurrection attacks, plus the workflow path with the @<ref> suffix stripped (any branch/tag can publish), plus environment only if pinned.
- The OIDC decoder is built lazily — `JwtDecoders.fromIssuerLocation()` does network discovery, which previously ran in the constructor and would have made app startup depend on external issuers.
- The tests for both providers added, also got offline matches() tests covering ref-independence, ID mismatch, and pinned environments.

Note: namespace-scoped tokens (the minted token has the registering user's full publish rights).
This is server-side work only; no CLI or WebUI changes added.
@cstamas cstamas force-pushed the trusted-publishing branch from 109d819 to d4b3cde Compare July 15, 2026 10:14
@PostConstruct
public void validate() {
if (enabled) {
if (audience == null || audience.isBlank()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that could be modelled with a @notblank annotation on the field

description = description.substring(0, TOKEN_DESCRIPTION_SIZE);
}
logger.info("Issuing trusted publishing token for namespace {} to {}", namespace.getName(), claims.get(JwtClaimNames.SUB));
return tokens.createAccessToken(match.getCreatedBy(), description, TimeUtil.getCurrentUTC().plus(config.getTokenExpiry()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that right now the PersonalAccessToken has a mandatory relationship to a user which is also displayed in the UI to show by whom the extension was published.

In the case of trusted publishing we also want to highlight that it was published like that and associating the publication with the user that created the trusted publishing setup does not feel right.

/**
* Claims are internal only; should not leave the boundaries of application.
* Hence, {@link #toJson()} omits it.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will need to display it though in the UI e.g. which repo / workflow has been setup for the trusted publisher.

Comment on lines +41 to +51
@Nullable
private String owner;

@Nullable
private String repo;

@Nullable
private String workflow;

@Nullable
private String environment;

@gnugomez gnugomez Jul 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: since you need to be the namespace owner in order to retrieve the trusted publishers, what's the harm of returning the owner, repo, workflow and environment values?

I think it would be a pretty useful information to display.

Do you think it is that sensitive?

cstamas added 2 commits July 15, 2026 14:09
So refactored a bit with comments, to make possible method
override in UT.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trusted publishing

3 participants