From 10c258cab6b899bea8faf3663bdf7464fcb55a3d Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 13:25:34 -0400 Subject: [PATCH 01/12] feat(secrethub): support audience override in OIDC JWT generation Adds an optional `:audience` field to the OIDC token request handled by `Secrethub.OpenIDConnect.JWT.generate_and_sign/1`. Behavior: * absent or empty list -> existing default `https://.` * single-element list -> string (RFC 7519 sec 4.1.3 convention, required by strict-aud consumers like PyPI Trusted Publishers) * multi-element list -> JSON array Purely additive: existing callers that do not set `:audience` are unaffected. Filed as part of the wider per-job `oidc_tokens` block work; gRPC plumbing and request-struct field are out of scope for this change and will land separately. --- .../lib/secrethub/open_id_connect/jwt.ex | 10 ++- .../secrethub/open_id_connect/jwt_test.exs | 84 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 secrethub/test/secrethub/open_id_connect/jwt_test.exs diff --git a/secrethub/lib/secrethub/open_id_connect/jwt.ex b/secrethub/lib/secrethub/open_id_connect/jwt.ex index a44916673..c39886c73 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt.ex @@ -107,6 +107,14 @@ defmodule Secrethub.OpenIDConnect.JWT do defp build_oidc_claims(req) do domain = Application.fetch_env!(:secrethub, :domain) + default_aud = "https://#{req.org_username}.#{domain}" + + aud = + case Map.get(req, :audience, []) do + [] -> default_aud + [single] -> single + list when is_list(list) -> list + end common_claims = %{ "org" => req.org_username, @@ -126,7 +134,7 @@ defmodule Secrethub.OpenIDConnect.JWT do "sub" => req.subject, "sub127" => build_subject_127(req), "iss" => "https://#{req.org_username}.#{domain}", - "aud" => "https://#{req.org_username}.#{domain}", + "aud" => aud, "job_type" => req.job_type, "pr_branch" => req.git_pull_request_branch, "repo_slug" => req.repo_slug, diff --git a/secrethub/test/secrethub/open_id_connect/jwt_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_test.exs new file mode 100644 index 000000000..8ad1a0d4c --- /dev/null +++ b/secrethub/test/secrethub/open_id_connect/jwt_test.exs @@ -0,0 +1,84 @@ +defmodule Secrethub.OpenIDConnect.JWTTest do + use Secrethub.DataCase + + alias Secrethub.OpenIDConnect.JWT + alias Support.FakeServices + + defp base_req(extra \\ %{}) do + org_id = Ecto.UUID.generate() + project_id = Ecto.UUID.generate() + repo = "web" + ref_type = "branch" + git_ref = "refs/heads/main" + + %{ + org_id: org_id, + org_username: "testera", + project_id: project_id, + project_name: "my-project", + workflow_id: Ecto.UUID.generate(), + pipeline_id: Ecto.UUID.generate(), + job_id: Ecto.UUID.generate(), + repository_name: repo, + git_tag: "", + git_ref: git_ref, + git_ref_type: ref_type, + git_branch_name: "main", + git_pull_request_number: "", + git_pull_request_branch: "", + job_type: "pipeline_job", + repo_slug: "renderedtext/#{repo}", + triggerer: "h:f,i:f", + subject: + "org:testera:project:#{project_id}:repo:#{repo}:ref_type:#{ref_type}:ref:#{git_ref}", + user_id: Ecto.UUID.generate(), + expires_in: 3600 + } + |> Map.merge(extra) + end + + describe "generate_and_sign/1 audience handling" do + setup do + FakeServices.enable_features([]) + :ok + end + + test "defaults to org URL when audience is absent" do + req = base_req() + domain = Application.fetch_env!(:secrethub, :domain) + + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + assert Map.get(jwt.fields, "aud") == "https://#{req.org_username}.#{domain}" + end + + test "defaults to org URL when audience is an empty list" do + req = base_req(%{audience: []}) + domain = Application.fetch_env!(:secrethub, :domain) + + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + assert Map.get(jwt.fields, "aud") == "https://#{req.org_username}.#{domain}" + end + + test "uses single string when audience is a single-element list (RFC 7519 convention)" do + req = base_req(%{audience: ["pypi"]}) + + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + assert Map.get(jwt.fields, "aud") == "pypi" + end + + test "uses JSON array when audience is a multi-element list" do + req = base_req(%{audience: ["pypi", "https://other.example"]}) + + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + assert Map.get(jwt.fields, "aud") == ["pypi", "https://other.example"] + end + end +end From bbbd12c4e1fd499c325e9b56823996a2c70b022a Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 13:35:08 -0400 Subject: [PATCH 02/12] feat(plumber): add oidc_tokens block to pipeline yaml schema Introduces a per-job `oidc_tokens` map keyed by env var name, with each entry requiring an `aud` value (string or non-empty list of strings). Keys are restricted to the env var pattern via patternProperties combined with additionalProperties: false. Reserved-name semantics (`SEMAPHORE_OIDC_TOKEN`) are intentionally not enforced at the schema level and will be handled by the semantic check in a follow-up task. Adds positive and negative fixtures for the existing pipelines directory test (missing aud, invalid env var name, empty aud list). --- plumber/spec/priv/v1.0.yml | 22 +++++++++++++++++++ .../v1.0-oidc-tokens-empty-aud-list.fail.yml | 16 ++++++++++++++ .../v1.0-oidc-tokens-invalid-key.fail.yml | 16 ++++++++++++++ .../v1.0-oidc-tokens-missing-aud.fail.yml | 15 +++++++++++++ .../spec/test/pipelines/v1.0-oidc-tokens.yml | 22 +++++++++++++++++++ 5 files changed, 91 insertions(+) create mode 100644 plumber/spec/test/pipelines/v1.0-oidc-tokens-empty-aud-list.fail.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc-tokens-invalid-key.fail.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc-tokens-missing-aud.fail.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc-tokens.yml diff --git a/plumber/spec/priv/v1.0.yml b/plumber/spec/priv/v1.0.yml index 1064c1ee1..a56ba8d68 100644 --- a/plumber/spec/priv/v1.0.yml +++ b/plumber/spec/priv/v1.0.yml @@ -222,6 +222,8 @@ definitions: $ref: "#/definitions/env_vars" execution_time_limit: $ref: "#/definitions/execution_time_limit" + oidc_tokens: + $ref: "#/definitions/oidc_tokens" additionalProperties: false not: anyOf: @@ -241,6 +243,26 @@ definitions: additionalProperties: false anyOf: - required: [type, job_count] + oidc_tokens: + type: object + patternProperties: + "^[A-Z_][A-Z0-9_]*$": + $ref: "#/definitions/oidc_token_spec" + additionalProperties: false + oidc_token_spec: + type: object + properties: + aud: + oneOf: + - type: string + minLength: 1 + - type: array + minItems: 1 + items: + type: string + minLength: 1 + additionalProperties: false + required: [aud] secrets: type: array items: diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens-empty-aud-list.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc-tokens-empty-aud-list.fail.yml new file mode 100644 index 000000000..dbb53e5a9 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc-tokens-empty-aud-list.fail.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens empty aud list +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Empty aud list + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: [] + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens-invalid-key.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc-tokens-invalid-key.fail.yml new file mode 100644 index 000000000..c64ac6793 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc-tokens-invalid-key.fail.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens invalid env var name +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Invalid env var name + oidc_tokens: + lowercase-bad: + aud: pypi + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens-missing-aud.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc-tokens-missing-aud.fail.yml new file mode 100644 index 000000000..68e94a701 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc-tokens-missing-aud.fail.yml @@ -0,0 +1,15 @@ +version: "v1.0" +name: OIDC tokens missing aud +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Missing aud + oidc_tokens: + PYPI_OIDC_TOKEN: {} + commands: + - echo "$PYPI_OIDC_TOKEN" diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens.yml b/plumber/spec/test/pipelines/v1.0-oidc-tokens.yml new file mode 100644 index 000000000..3fca851f8 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc-tokens.yml @@ -0,0 +1,22 @@ +version: "v1.0" +name: OIDC tokens test +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Single audience (string) + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: pypi + commands: + - echo "$PYPI_OIDC_TOKEN" + - name: Multiple audiences (list) + oidc_tokens: + NPM_TOKEN: + aud: ["npm-prod", "npm-staging"] + commands: + - echo "$NPM_TOKEN" From e59f396ea24f797bc4092148bcb9ca56bc210c7a Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 13:42:06 -0400 Subject: [PATCH 03/12] fix(plumber): rename oidc_tokens fixtures to snake_case + add bounds - Rename 4 fixture files to match existing snake_case convention - maxProperties: 16 on oidc_tokens map (cap entries per job) - maxLength: 256 on aud strings, maxItems: 8 on aud arrays (hardening) --- plumber/spec/priv/v1.0.yml | 4 ++++ .../pipelines/{v1.0-oidc-tokens.yml => v1.0-oidc_tokens.yml} | 0 ...list.fail.yml => v1.0-oidc_tokens_empty_aud_list.fail.yml} | 0 ...lid-key.fail.yml => v1.0-oidc_tokens_invalid_key.fail.yml} | 0 ...ing-aud.fail.yml => v1.0-oidc_tokens_missing_aud.fail.yml} | 0 5 files changed, 4 insertions(+) rename plumber/spec/test/pipelines/{v1.0-oidc-tokens.yml => v1.0-oidc_tokens.yml} (100%) rename plumber/spec/test/pipelines/{v1.0-oidc-tokens-empty-aud-list.fail.yml => v1.0-oidc_tokens_empty_aud_list.fail.yml} (100%) rename plumber/spec/test/pipelines/{v1.0-oidc-tokens-invalid-key.fail.yml => v1.0-oidc_tokens_invalid_key.fail.yml} (100%) rename plumber/spec/test/pipelines/{v1.0-oidc-tokens-missing-aud.fail.yml => v1.0-oidc_tokens_missing_aud.fail.yml} (100%) diff --git a/plumber/spec/priv/v1.0.yml b/plumber/spec/priv/v1.0.yml index a56ba8d68..30bf7e4b1 100644 --- a/plumber/spec/priv/v1.0.yml +++ b/plumber/spec/priv/v1.0.yml @@ -249,6 +249,7 @@ definitions: "^[A-Z_][A-Z0-9_]*$": $ref: "#/definitions/oidc_token_spec" additionalProperties: false + maxProperties: 16 oidc_token_spec: type: object properties: @@ -256,11 +257,14 @@ definitions: oneOf: - type: string minLength: 1 + maxLength: 256 - type: array minItems: 1 + maxItems: 8 items: type: string minLength: 1 + maxLength: 256 additionalProperties: false required: [aud] secrets: diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens.yml similarity index 100% rename from plumber/spec/test/pipelines/v1.0-oidc-tokens.yml rename to plumber/spec/test/pipelines/v1.0-oidc_tokens.yml diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens-empty-aud-list.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_empty_aud_list.fail.yml similarity index 100% rename from plumber/spec/test/pipelines/v1.0-oidc-tokens-empty-aud-list.fail.yml rename to plumber/spec/test/pipelines/v1.0-oidc_tokens_empty_aud_list.fail.yml diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens-invalid-key.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_invalid_key.fail.yml similarity index 100% rename from plumber/spec/test/pipelines/v1.0-oidc-tokens-invalid-key.fail.yml rename to plumber/spec/test/pipelines/v1.0-oidc_tokens_invalid_key.fail.yml diff --git a/plumber/spec/test/pipelines/v1.0-oidc-tokens-missing-aud.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_missing_aud.fail.yml similarity index 100% rename from plumber/spec/test/pipelines/v1.0-oidc-tokens-missing-aud.fail.yml rename to plumber/spec/test/pipelines/v1.0-oidc_tokens_missing_aud.fail.yml From 00aef4fea968672a7ba08be60970ffbcdb6ec235 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 14:02:55 -0400 Subject: [PATCH 04/12] feat(plumber): semantic validation for oidc_tokens Reject SEMAPHORE_OIDC_TOKEN as a custom token name (reserved for the auto-injected default token). Env var name format ([A-Z_][A-Z0-9_]*) is enforced at the JSON schema layer via patternProperties. Duplicate token names are impossible at the yaml level (map-shaped block). --- plumber/ppl/lib/ppl/definition_reviser.ex | 17 +- .../oidc_tokens_validator.ex | 95 +++++++++++ .../oidc_tokens_validator_test.exs | 147 ++++++++++++++++++ 3 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex create mode 100644 plumber/ppl/test/definition_reviser/oidc_tokens_validator_test.exs diff --git a/plumber/ppl/lib/ppl/definition_reviser.ex b/plumber/ppl/lib/ppl/definition_reviser.ex index ce812f37f..df4e24529 100644 --- a/plumber/ppl/lib/ppl/definition_reviser.ex +++ b/plumber/ppl/lib/ppl/definition_reviser.ex @@ -4,9 +4,17 @@ defmodule Ppl.DefinitionReviser do """ alias Ppl.DefinitionReviser.{ - BlocksGodfather, BlocksReviser, JobsGodfather, Task2Build, - TaskFileProperty, ImplicitDependency, WhenValidator, - MaxTimeLimitChecker, ParallelismValidator, JobMatrixValidator + BlocksGodfather, + BlocksReviser, + JobsGodfather, + Task2Build, + TaskFileProperty, + ImplicitDependency, + WhenValidator, + MaxTimeLimitChecker, + ParallelismValidator, + JobMatrixValidator, + OIDCTokensValidator } def revise_definition(definition, ppl_req) do @@ -19,6 +27,7 @@ defmodule Ppl.DefinitionReviser do {:ok, definition} <- BlocksReviser.revise_blocks_definition(definition, ppl_req), {:ok, definition} <- ParallelismValidator.validate(definition), {:ok, definition} <- JobMatrixValidator.validate(definition), - do: ImplicitDependency.convert_to_explicit(definition) + {:ok, definition} <- OIDCTokensValidator.validate(definition), + do: ImplicitDependency.convert_to_explicit(definition) end end diff --git a/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex b/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex new file mode 100644 index 000000000..dc7b49739 --- /dev/null +++ b/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex @@ -0,0 +1,95 @@ +defmodule Ppl.DefinitionReviser.OIDCTokensValidator do + @moduledoc """ + Validates the `oidc_tokens` block on jobs. + + Schema-level validation (env var name regex, `aud` shape) happens earlier in + the definition validator. This module enforces semantic rules that JSON + Schema cannot express. + + Currently it rejects the reserved key `SEMAPHORE_OIDC_TOKEN`: that name is + used for the auto-injected default OIDC token, so allowing it as a custom + token name would clobber the job's environment variable. + """ + + alias Util.ToTuple + + @reserved_token_names ~w(SEMAPHORE_OIDC_TOKEN) + + def validate(definition) do + with {:ok, definition} <- do_validate(definition, "blocks"), + {:ok, definition} <- do_validate(definition, "after_pipeline") do + ToTuple.ok(definition) + else + {:error, _} = error -> error + end + end + + defp do_validate(definition, "blocks") do + with {:ok, blocks} <- Map.fetch(definition, "blocks"), + :ok <- validate_blocks(blocks) do + {:ok, definition} + end + end + + defp do_validate(definition, "after_pipeline") do + case Map.get(definition, "after_pipeline") do + nil -> + {:ok, definition} + + blocks -> + blocks = Enum.map(blocks, &Map.put(&1, "name", "after_pipeline")) + + case validate_blocks(blocks) do + :ok -> {:ok, definition} + {:error, _} = error -> error + end + end + end + + defp validate_blocks(blocks) do + Enum.reduce_while(blocks, :ok, fn block, :ok -> + case validate_block(block) do + :ok -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp validate_block(block) do + block_name = Map.get(block, "name") + jobs = block |> get_in(["build", "jobs"]) |> List.wrap() + + Enum.reduce_while(jobs, :ok, fn job, :ok -> + case validate_job(block_name, job) do + :ok -> {:cont, :ok} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp validate_job(block_name, job) do + case Map.get(job, "oidc_tokens") do + tokens when is_map(tokens) -> + check_reserved_names(block_name, Map.get(job, "name"), tokens) + + _ -> + :ok + end + end + + defp check_reserved_names(block_name, job_name, tokens) do + case Enum.find(@reserved_token_names, &Map.has_key?(tokens, &1)) do + nil -> + :ok + + reserved -> + message = reserved_name_error(block_name, job_name, reserved) + {:error, {:malformed, message}} + end + end + + defp reserved_name_error(block_name, job_name, name) do + "Token name '#{name}' is reserved and cannot be used as a custom oidc_tokens key " <> + "(block '#{block_name}', job '#{job_name}'). Use a different environment variable name." + end +end diff --git a/plumber/ppl/test/definition_reviser/oidc_tokens_validator_test.exs b/plumber/ppl/test/definition_reviser/oidc_tokens_validator_test.exs new file mode 100644 index 000000000..6e49bb085 --- /dev/null +++ b/plumber/ppl/test/definition_reviser/oidc_tokens_validator_test.exs @@ -0,0 +1,147 @@ +defmodule Ppl.DefinitionReviser.OIDCTokensValidator.Test do + use ExUnit.Case, async: true + alias Ppl.DefinitionReviser.OIDCTokensValidator + + describe "validate oidc_tokens block" do + test "pipeline without any oidc_tokens block passes validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{"name" => "Job 1", "commands" => ["true"]} + ] + } + } + ] + } + + assert OIDCTokensValidator.validate(pipeline) == {:ok, pipeline} + end + + test "job with empty oidc_tokens map passes validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{"name" => "Job 1", "oidc_tokens" => %{}} + ] + } + } + ] + } + + assert OIDCTokensValidator.validate(pipeline) == {:ok, pipeline} + end + + test "job with valid oidc_tokens map passes validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "oidc_tokens" => %{ + "PYPI_OIDC_TOKEN" => %{"aud" => "pypi"}, + "NPM_TOKEN" => %{"aud" => ["npm-prod"]} + } + } + ] + } + } + ] + } + + assert OIDCTokensValidator.validate(pipeline) == {:ok, pipeline} + end + + test "job using SEMAPHORE_OIDC_TOKEN as a custom token name fails validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "oidc_tokens" => %{"SEMAPHORE_OIDC_TOKEN" => %{"aud" => "pypi"}} + } + ] + } + } + ] + } + + assert {:error, {:malformed, msg}} = OIDCTokensValidator.validate(pipeline) + + assert msg =~ "SEMAPHORE_OIDC_TOKEN" + assert msg =~ "reserved" + assert msg =~ "Block 1" + assert msg =~ "Job 1" + end + + test "after_pipeline job using SEMAPHORE_OIDC_TOKEN fails validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [%{"name" => "Job 1"}] + } + } + ], + "after_pipeline" => [ + %{ + "build" => %{ + "jobs" => [ + %{ + "name" => "After Job 1", + "oidc_tokens" => %{"SEMAPHORE_OIDC_TOKEN" => %{"aud" => "pypi"}} + } + ] + } + } + ] + } + + assert {:error, {:malformed, msg}} = OIDCTokensValidator.validate(pipeline) + + assert msg =~ "SEMAPHORE_OIDC_TOKEN" + assert msg =~ "reserved" + assert msg =~ "after_pipeline" + assert msg =~ "After Job 1" + end + + test "valid oidc_tokens alongside another job with reserved name fails validation" do + pipeline = %{ + "blocks" => [ + %{ + "name" => "Block 1", + "build" => %{ + "jobs" => [ + %{ + "name" => "Job 1", + "oidc_tokens" => %{"PYPI_OIDC_TOKEN" => %{"aud" => "pypi"}} + }, + %{ + "name" => "Job 2", + "oidc_tokens" => %{"SEMAPHORE_OIDC_TOKEN" => %{"aud" => "x"}} + } + ] + } + } + ] + } + + assert {:error, {:malformed, msg}} = OIDCTokensValidator.validate(pipeline) + + assert msg =~ "SEMAPHORE_OIDC_TOKEN" + assert msg =~ "Job 2" + end + end +end From d43759da097f7fd9cd88ae194ea08b3f8bb9a11a Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 14:12:47 -0400 Subject: [PATCH 05/12] docs: document oidc_tokens block for custom audiences Adds a "Custom audiences" section to the OIDC user guide with a complete PyPI publishing example, and a corresponding reference section documenting the schema, behavior, and validation errors. --- docs/docs/reference/openid.md | 38 +++++++++++++++++ docs/docs/using-semaphore/openid.md | 65 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/docs/docs/reference/openid.md b/docs/docs/reference/openid.md index d6afaba15..9b6eb8de5 100644 --- a/docs/docs/reference/openid.md +++ b/docs/docs/reference/openid.md @@ -48,6 +48,44 @@ A token with the above claims is exported into jobs as the `SEMAPHORE_OIDC_TOKEN If the cloud provider is configured to accept OIDC tokens, it will receive the token, verify its signature by connecting back to `.semaphoreci.com.well-known/jwts`, and if the token is valid, it will respond with a short-lived token for this specific job that can be used to fetch and modify cloud resources. +## `oidc_tokens` block + +Jobs can declare additional OIDC tokens with custom audiences using a per-job `oidc_tokens:` block. This is needed for consumers that strictly verify a specific `aud` value (such as [PyPI's trusted publishers](https://docs.pypi.org/trusted-publishers/), which requires `aud="pypi"`). + +```yaml +jobs: + - name: Publish + oidc_tokens: + : + aud: + commands: + - ... +``` + +### Schema + +| Field | Required | Type | Description | +|---|---|---|---| +| `` (the yaml key) | yes | string | Name of the environment variable. Must match `^[A-Z_][A-Z0-9_]*$`. The reserved name `SEMAPHORE_OIDC_TOKEN` is not allowed (it's the auto-injected default). | +| `aud` | yes | string OR non-empty list of strings | Value of the JWT `aud` claim. A string becomes JWT `"aud": ""`; a list becomes JWT `"aud": []` per [RFC 7519 §4.1.3](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3). Up to 8 audiences allowed; each up to 256 characters. | + +A job may declare up to 16 entries. + +### Behavior + +- Each entry in `oidc_tokens` produces one additional minted token, exposed as the named environment variable (the yaml key). +- The default `SEMAPHORE_OIDC_TOKEN` is always still injected when the OIDC feature is enabled — `oidc_tokens` is purely additive. +- All tokens share the same TTL and the same claim set as the default token, except for `aud` (per the request) and `jti` (always unique per token). + +### Validation errors + +| Error | Cause | +|---|---| +| Schema validation failure on `oidc_tokens` | Yaml key doesn't match the env var name regex, `aud` is missing or wrong shape, or extra unknown properties present | +| `'SEMAPHORE_OIDC_TOKEN' is reserved; use a different env var name` | Reserved key used as a custom token name | + +See the [Custom audiences](../using-semaphore/openid#custom-audiences) section of the OIDC guide for usage examples. + ## See also - [Pipeline YAML](./pipeline-yaml) diff --git a/docs/docs/using-semaphore/openid.md b/docs/docs/using-semaphore/openid.md index f472e72ce..50175fb3c 100644 --- a/docs/docs/using-semaphore/openid.md +++ b/docs/docs/using-semaphore/openid.md @@ -377,6 +377,71 @@ vault kv get -field=value secret/data/production/my-secret +## Custom audiences with `oidc_tokens` {#custom-audiences} + +By default, Semaphore injects `SEMAPHORE_OIDC_TOKEN` into every job with the `aud` claim set to your organization URL (`https://.semaphoreci.com`). This works for cloud providers like AWS, Google Cloud, and HashiCorp Vault that accept any audience matching the configured identity provider. + +Some token consumers — including [PyPI's trusted publishers](https://docs.pypi.org/trusted-publishers/), npm trusted publishing, and other registries — require the OIDC token to carry a specific audience string (e.g. `aud: pypi`). For these cases, declare additional tokens in a per-job `oidc_tokens:` block. + +### Example: publishing to PyPI + +```yaml +version: v1.0 +name: Publish to PyPI +agent: + machine: + type: e1-standard-2 + os_image: ubuntu2004 +blocks: + - name: Publish + task: + jobs: + - name: Build and upload + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: pypi + commands: + - checkout + - sem-version python 3.11 + - python -m pip install --upgrade build twine + - python -m build + - 'API_TOKEN=$(curl -fsS -X POST https://pypi.org/_/oidc/mint-token \ + -H "Content-Type: application/json" \ + -d "{\"token\":\"$PYPI_OIDC_TOKEN\"}" | jq -r .token)' + - 'TWINE_USERNAME=__token__ TWINE_PASSWORD=$API_TOKEN python -m twine upload dist/*' +``` + +### How it works + +- Each entry in `oidc_tokens` produces one additional minted token with the requested `aud`. The token is exposed as the named environment variable (the yaml key). +- The default `SEMAPHORE_OIDC_TOKEN` is **always** still injected with the org-URL audience — your existing AWS / Vault flows continue to work unchanged. +- All tokens (default + `oidc_tokens` entries) share the same TTL (24h) and the same claim set, except for `aud` (per the request) and `jti` (always unique per token). + +### Validation rules + +The yaml key (the env var name) must match `^[A-Z_][A-Z0-9_]*$`. The reserved name `SEMAPHORE_OIDC_TOKEN` cannot be used as a custom token name (it's the auto-injected default). + +`aud` is required and must be either: + +- a string (becomes JWT `"aud": ""`), or +- a non-empty list of strings (becomes JWT `"aud": []`, per [RFC 7519 §4.1.3](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3)). + +A pipeline that omits `aud`, uses an invalid env var name, or collides with `SEMAPHORE_OIDC_TOKEN` is rejected at submission time. + +### Multiple consumers in one job + +You can declare multiple tokens with different audiences in the same job: + +```yaml +oidc_tokens: + PYPI_OIDC_TOKEN: + aud: pypi + NPM_OIDC_TOKEN: + aud: ["npm-prod", "npm-staging"] + DOCKERHUB_OIDC_TOKEN: + aud: registry.hub.docker.com +``` + ## Implementation details See the [OIDC token reference page](../reference/openid) to learn how the OIDC tokens are created and what fields are available. From 9938fdd09d5e3a1b99e5fc9a47c9b8f102d544b2 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 22:52:23 -0400 Subject: [PATCH 06/12] fix(secrethub): handle nil audience and lock down iss/filter invariants - Handle audience: nil (key present, nil value) by falling back to default - Add iss assertion to existing tests (locks in iss/aud independence) - Add JWTFilter interaction test (verifies aud survives filter when enabled) --- .../lib/secrethub/open_id_connect/jwt.ex | 1 + .../secrethub/open_id_connect/jwt_test.exs | 55 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/secrethub/lib/secrethub/open_id_connect/jwt.ex b/secrethub/lib/secrethub/open_id_connect/jwt.ex index c39886c73..9697aee3b 100644 --- a/secrethub/lib/secrethub/open_id_connect/jwt.ex +++ b/secrethub/lib/secrethub/open_id_connect/jwt.ex @@ -111,6 +111,7 @@ defmodule Secrethub.OpenIDConnect.JWT do aud = case Map.get(req, :audience, []) do + nil -> default_aud [] -> default_aud [single] -> single list when is_list(list) -> list diff --git a/secrethub/test/secrethub/open_id_connect/jwt_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_test.exs index 8ad1a0d4c..169479cc9 100644 --- a/secrethub/test/secrethub/open_id_connect/jwt_test.exs +++ b/secrethub/test/secrethub/open_id_connect/jwt_test.exs @@ -1,7 +1,9 @@ defmodule Secrethub.OpenIDConnect.JWTTest do use Secrethub.DataCase - alias Secrethub.OpenIDConnect.JWT + import Mock + + alias Secrethub.OpenIDConnect.{JWT, JWTConfiguration} alias Support.FakeServices defp base_req(extra \\ %{}) do @@ -46,11 +48,13 @@ defmodule Secrethub.OpenIDConnect.JWTTest do test "defaults to org URL when audience is absent" do req = base_req() domain = Application.fetch_env!(:secrethub, :domain) + expected_iss = "https://#{req.org_username}.#{domain}" assert {:ok, token} = JWT.generate_and_sign(req) assert {true, jwt, _} = JWT.verify(token) assert Map.get(jwt.fields, "aud") == "https://#{req.org_username}.#{domain}" + assert Map.get(jwt.fields, "iss") == expected_iss end test "defaults to org URL when audience is an empty list" do @@ -63,6 +67,16 @@ defmodule Secrethub.OpenIDConnect.JWTTest do assert Map.get(jwt.fields, "aud") == "https://#{req.org_username}.#{domain}" end + test "uses default org URL when audience is nil" do + req = base_req() |> Map.put(:audience, nil) + domain = Application.fetch_env!(:secrethub, :domain) + + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + assert Map.get(jwt.fields, "aud") == "https://#{req.org_username}.#{domain}" + end + test "uses single string when audience is a single-element list (RFC 7519 convention)" do req = base_req(%{audience: ["pypi"]}) @@ -74,11 +88,50 @@ defmodule Secrethub.OpenIDConnect.JWTTest do test "uses JSON array when audience is a multi-element list" do req = base_req(%{audience: ["pypi", "https://other.example"]}) + domain = Application.fetch_env!(:secrethub, :domain) + expected_iss = "https://#{req.org_username}.#{domain}" assert {:ok, token} = JWT.generate_and_sign(req) assert {true, jwt, _} = JWT.verify(token) assert Map.get(jwt.fields, "aud") == ["pypi", "https://other.example"] + assert Map.get(jwt.fields, "iss") == expected_iss + end + end + + describe "generate_and_sign/1 with :open_id_connect_filter enabled" do + setup do + FakeServices.enable_features(["open_id_connect_filter"]) + :ok + end + + test "preserves audience override when aud is allowlisted" do + req = base_req(%{audience: ["pypi"]}) + + # Configure the JWT filter to allowlist `aud` (and the other claims used + # by build_oidc_claims that we want surfaced for the test). When the + # :open_id_connect_filter feature flag is enabled, only allowlisted + # claims survive into the signed token. + with_mock(JWTConfiguration, [], + get_org_config: fn _org_id -> + {:ok, + %{ + claims: [ + %{"name" => "aud", "is_active" => true}, + %{"name" => "iss", "is_active" => true}, + %{"name" => "sub", "is_active" => true}, + %{"name" => "exp", "is_active" => true}, + %{"name" => "iat", "is_active" => true}, + %{"name" => "nbf", "is_active" => true} + ] + }} + end + ) do + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + assert Map.get(jwt.fields, "aud") == "pypi" + end end end end From 22e1b9690ac21df23da5694d215faed179b3ec1d Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 22:52:35 -0400 Subject: [PATCH 07/12] test(plumber): add fixtures for oidc_tokens hardening bounds Adds .fail.yml fixtures verifying maxProperties: 16, maxLength: 256, maxItems: 8, and minLength: 1 (empty aud string) on the oidc_tokens schema. --- ...v1.0-oidc_tokens_aud_empty_string.fail.yml | 16 +++++++ .../v1.0-oidc_tokens_aud_too_long.fail.yml | 16 +++++++ ....0-oidc_tokens_too_many_audiences.fail.yml | 16 +++++++ .../v1.0-oidc_tokens_too_many_tokens.fail.yml | 48 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_empty_string.fail.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_too_long.fail.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_audiences.fail.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_tokens.fail.yml diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_empty_string.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_empty_string.fail.yml new file mode 100644 index 000000000..7f77ad215 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_empty_string.fail.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens aud empty string +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Aud empty string violates minLength 1 + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: "" + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_too_long.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_too_long.fail.yml new file mode 100644 index 000000000..cbab7be68 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_aud_too_long.fail.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens aud too long +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Aud string exceeds maxLength 256 + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_audiences.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_audiences.fail.yml new file mode 100644 index 000000000..a98ead29c --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_audiences.fail.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens too many audiences +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Aud list exceeds maxItems 8 + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9"] + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_tokens.fail.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_tokens.fail.yml new file mode 100644 index 000000000..dffbf3728 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_too_many_tokens.fail.yml @@ -0,0 +1,48 @@ +version: "v1.0" +name: OIDC tokens too many tokens +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Too many tokens + oidc_tokens: + T1: + aud: pypi + T2: + aud: pypi + T3: + aud: pypi + T4: + aud: pypi + T5: + aud: pypi + T6: + aud: pypi + T7: + aud: pypi + T8: + aud: pypi + T9: + aud: pypi + T10: + aud: pypi + T11: + aud: pypi + T12: + aud: pypi + T13: + aud: pypi + T14: + aud: pypi + T15: + aud: pypi + T16: + aud: pypi + T17: + aud: pypi + commands: + - echo hi From 12138568b646cd8aa3799c59e84cd4bd360b5506 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 22:52:48 -0400 Subject: [PATCH 08/12] docs: align oidc_tokens documentation with implementation - Clarify that single-element aud lists flatten to strings (RFC 7519) - Add "coming soon" banner noting Zebra runtime injection is pending - Genericize the reserved-name validation error description - Document schema limits in the user guide - Add oidc_tokens cross-reference in the canonical pipeline-yaml reference --- docs/docs/reference/openid.md | 10 ++++++++-- docs/docs/reference/pipeline-yaml.md | 16 ++++++++++++++++ docs/docs/using-semaphore/openid.md | 10 +++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/docs/reference/openid.md b/docs/docs/reference/openid.md index 9b6eb8de5..6fdde64c3 100644 --- a/docs/docs/reference/openid.md +++ b/docs/docs/reference/openid.md @@ -50,6 +50,12 @@ If the cloud provider is configured to accept OIDC tokens, it will receive the t ## `oidc_tokens` block +:::caution Coming soon + +The `oidc_tokens` block is being added in stages. The yaml schema and validation are available now; runtime injection of the named environment variables is part of a follow-up release. Pipelines using `oidc_tokens` will pass validation, but the env vars will not yet be populated until the runtime wiring lands. + +::: + Jobs can declare additional OIDC tokens with custom audiences using a per-job `oidc_tokens:` block. This is needed for consumers that strictly verify a specific `aud` value (such as [PyPI's trusted publishers](https://docs.pypi.org/trusted-publishers/), which requires `aud="pypi"`). ```yaml @@ -67,7 +73,7 @@ jobs: | Field | Required | Type | Description | |---|---|---|---| | `` (the yaml key) | yes | string | Name of the environment variable. Must match `^[A-Z_][A-Z0-9_]*$`. The reserved name `SEMAPHORE_OIDC_TOKEN` is not allowed (it's the auto-injected default). | -| `aud` | yes | string OR non-empty list of strings | Value of the JWT `aud` claim. A string becomes JWT `"aud": ""`; a list becomes JWT `"aud": []` per [RFC 7519 §4.1.3](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3). Up to 8 audiences allowed; each up to 256 characters. | +| `aud` | yes | string OR non-empty list of strings | Value of the JWT `aud` claim. A string becomes JWT `"aud": ""`; a list of two or more strings becomes JWT `"aud": []` per [RFC 7519 §4.1.3](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3). A single-element list (e.g. `aud: ["pypi"]`) is normalized to a string in the JWT, per RFC 7519's convention for the single-audience case — required for consumers like PyPI that strictly verify `aud` as a string. Up to 8 audiences allowed; each up to 256 characters. | A job may declare up to 16 entries. @@ -82,7 +88,7 @@ A job may declare up to 16 entries. | Error | Cause | |---|---| | Schema validation failure on `oidc_tokens` | Yaml key doesn't match the env var name regex, `aud` is missing or wrong shape, or extra unknown properties present | -| `'SEMAPHORE_OIDC_TOKEN' is reserved; use a different env var name` | Reserved key used as a custom token name | +| Reserved name `SEMAPHORE_OIDC_TOKEN` used as a custom token key | Reserved key used as a custom token name | See the [Custom audiences](../using-semaphore/openid#custom-audiences) section of the OIDC guide for usage examples. diff --git a/docs/docs/reference/pipeline-yaml.md b/docs/docs/reference/pipeline-yaml.md index 286f07c56..7a91376e9 100644 --- a/docs/docs/reference/pipeline-yaml.md +++ b/docs/docs/reference/pipeline-yaml.md @@ -1213,6 +1213,22 @@ It is not possible to have both `parallelism` and [`matrix`](#matrix-in-jobs) pr ::: +### oidc_tokens {#oidc-tokens-in-jobs} + +The `oidc_tokens` property declares additional [OpenID Connect (OIDC) tokens](./openid#oidc_tokens-block) with custom audiences, exposed as named environment variables in the job. This is required for token consumers (like PyPI's trusted publishers) that strictly verify a specific `aud` claim value. + +```yaml title="Example" +jobs: + - name: Publish to PyPI + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: pypi + commands: + - echo "$PYPI_OIDC_TOKEN" +``` + +See the [`oidc_tokens` block reference](./openid#oidc_tokens-block) for the full schema, validation rules, and limits. + ## after_pipeline {#after_pipeline} Defines a set of jobs to execute when the pipeline is finished. The `after_pipeline` property is most commonly used for sending notifications, collecting test results, and submitting metrics. diff --git a/docs/docs/using-semaphore/openid.md b/docs/docs/using-semaphore/openid.md index 50175fb3c..155735d62 100644 --- a/docs/docs/using-semaphore/openid.md +++ b/docs/docs/using-semaphore/openid.md @@ -379,6 +379,12 @@ vault kv get -field=value secret/data/production/my-secret ## Custom audiences with `oidc_tokens` {#custom-audiences} +:::caution Coming soon + +The `oidc_tokens` block is being added in stages. The yaml schema and validation are available now; runtime injection of the named environment variables is part of a follow-up release. Pipelines using `oidc_tokens` will pass validation, but the env vars will not yet be populated until the runtime wiring lands. + +::: + By default, Semaphore injects `SEMAPHORE_OIDC_TOKEN` into every job with the `aud` claim set to your organization URL (`https://.semaphoreci.com`). This works for cloud providers like AWS, Google Cloud, and HashiCorp Vault that accept any audience matching the configured identity provider. Some token consumers — including [PyPI's trusted publishers](https://docs.pypi.org/trusted-publishers/), npm trusted publishing, and other registries — require the OIDC token to carry a specific audience string (e.g. `aud: pypi`). For these cases, declare additional tokens in a per-job `oidc_tokens:` block. @@ -424,10 +430,12 @@ The yaml key (the env var name) must match `^[A-Z_][A-Z0-9_]*$`. The reserved na `aud` is required and must be either: - a string (becomes JWT `"aud": ""`), or -- a non-empty list of strings (becomes JWT `"aud": []`, per [RFC 7519 §4.1.3](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3)). +- a list of two or more strings (becomes JWT `"aud": []`, per [RFC 7519 §4.1.3](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3)). A single-element list (e.g., `aud: ["pypi"]`) is normalized to a string in the JWT, per RFC 7519's convention for the single-audience case — this is necessary for consumers like PyPI that strictly verify `aud` as a string. A pipeline that omits `aud`, uses an invalid env var name, or collides with `SEMAPHORE_OIDC_TOKEN` is rejected at submission time. +The schema enforces these limits: up to 16 entries per job, each with an `aud` of up to 8 audiences, each audience up to 256 characters. Pipelines exceeding these limits are rejected at validation time. + ### Multiple consumers in one job You can declare multiple tokens with different audiences in the same job: From 9e578bff8ce61a7ba506dd5cdb1150b01ffdb0d4 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 23:03:08 -0400 Subject: [PATCH 09/12] chore: minor doc and typespec polish from review - Drop hard-coded "24h" TTL from user guide (rot risk; reference page intentionally omits the value) - Re-add the "when OIDC is enabled" qualifier to the user guide's default-token claim (matches reference page) - Remove leftover LLM scaffolding line in reference/openid.md - Add @spec to OIDCTokensValidator.validate/1 --- docs/docs/reference/openid.md | 2 -- docs/docs/using-semaphore/openid.md | 4 ++-- .../ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex | 4 ++++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/docs/reference/openid.md b/docs/docs/reference/openid.md index 6fdde64c3..47274e783 100644 --- a/docs/docs/reference/openid.md +++ b/docs/docs/reference/openid.md @@ -14,8 +14,6 @@ Semaphore generates a unique OIDC token for every job. The token is injected int The token consists of a JWT token signed by Semaphore and contains the following claims. -Sure, here is the reordered list presented in a table with three columns: Claim, Description, Example. - | Claim | Description | Example | |-------------|-----------------------------------------------------------|--------------------------------------| | iss | The issuer of the token. The full URL of the organization | `https://.semaphoreci.com` | diff --git a/docs/docs/using-semaphore/openid.md b/docs/docs/using-semaphore/openid.md index 155735d62..2d68cdefe 100644 --- a/docs/docs/using-semaphore/openid.md +++ b/docs/docs/using-semaphore/openid.md @@ -420,8 +420,8 @@ blocks: ### How it works - Each entry in `oidc_tokens` produces one additional minted token with the requested `aud`. The token is exposed as the named environment variable (the yaml key). -- The default `SEMAPHORE_OIDC_TOKEN` is **always** still injected with the org-URL audience — your existing AWS / Vault flows continue to work unchanged. -- All tokens (default + `oidc_tokens` entries) share the same TTL (24h) and the same claim set, except for `aud` (per the request) and `jti` (always unique per token). +- The default `SEMAPHORE_OIDC_TOKEN` is still injected with the org-URL audience whenever the OIDC feature is enabled for the organization — your existing AWS / Vault flows continue to work unchanged. +- All tokens (default + `oidc_tokens` entries) share the same TTL and the same claim set, except for `aud` (per the request) and `jti` (always unique per token). ### Validation rules diff --git a/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex b/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex index dc7b49739..63ef2577c 100644 --- a/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex +++ b/plumber/ppl/lib/ppl/definition_reviser/oidc_tokens_validator.ex @@ -15,6 +15,10 @@ defmodule Ppl.DefinitionReviser.OIDCTokensValidator do @reserved_token_names ~w(SEMAPHORE_OIDC_TOKEN) + @type definition :: map() + @type error :: {:error, {:malformed, String.t()}} + + @spec validate(definition) :: {:ok, definition} | error def validate(definition) do with {:ok, definition} <- do_validate(definition, "blocks"), {:ok, definition} <- do_validate(definition, "after_pipeline") do From 6e56ce3d85898d65692da9c7b8bc1234951e9129 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 23:19:35 -0400 Subject: [PATCH 10/12] chore: round-3 review polish - Strengthen JWTFilter test (asserts filter actually ran by omitting `sub` from the allowlist mock and refuting it survived into the JWT) - Add complementary "aud not allowlisted" filter test verifying that a user-supplied custom audience is stripped when `aud` is not in the org claim allowlist - Add positive boundary fixtures at exactly 16 tokens / 8 audiences / 256-char aud so a regression that tightens any of those bounds flips a passing fixture to failing - Mirror the "Coming soon" admonition into pipeline-yaml.md so all three docs (user guide, openid reference, pipeline-yaml reference) are in sync - Add iss assertion to the single-element audience override test (the most regression-prone unwrap path) - Add explicit `{#oidc_tokens-block}` anchor to the openid heading so the cross-link from pipeline-yaml.md does not depend on auto-slug behavior --- docs/docs/reference/openid.md | 2 +- docs/docs/reference/pipeline-yaml.md | 6 +++ .../v1.0-oidc_tokens_at_max_aud_length.yml | 16 +++++++ .../v1.0-oidc_tokens_at_max_audiences.yml | 16 +++++++ .../v1.0-oidc_tokens_at_max_tokens.yml | 46 +++++++++++++++++++ .../secrethub/open_id_connect/jwt_test.exs | 41 ++++++++++++++++- 6 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_aud_length.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_audiences.yml create mode 100644 plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_tokens.yml diff --git a/docs/docs/reference/openid.md b/docs/docs/reference/openid.md index 47274e783..5d3d1ae11 100644 --- a/docs/docs/reference/openid.md +++ b/docs/docs/reference/openid.md @@ -46,7 +46,7 @@ A token with the above claims is exported into jobs as the `SEMAPHORE_OIDC_TOKEN If the cloud provider is configured to accept OIDC tokens, it will receive the token, verify its signature by connecting back to `.semaphoreci.com.well-known/jwts`, and if the token is valid, it will respond with a short-lived token for this specific job that can be used to fetch and modify cloud resources. -## `oidc_tokens` block +## `oidc_tokens` block {#oidc_tokens-block} :::caution Coming soon diff --git a/docs/docs/reference/pipeline-yaml.md b/docs/docs/reference/pipeline-yaml.md index 7a91376e9..89cda8c23 100644 --- a/docs/docs/reference/pipeline-yaml.md +++ b/docs/docs/reference/pipeline-yaml.md @@ -1215,6 +1215,12 @@ It is not possible to have both `parallelism` and [`matrix`](#matrix-in-jobs) pr ### oidc_tokens {#oidc-tokens-in-jobs} +:::caution Coming soon + +The `oidc_tokens` block is being added in stages. The yaml schema and validation are available now; runtime injection of the named environment variables is part of a follow-up release. Pipelines using `oidc_tokens` will pass validation, but the env vars will not yet be populated until the runtime wiring lands. + +::: + The `oidc_tokens` property declares additional [OpenID Connect (OIDC) tokens](./openid#oidc_tokens-block) with custom audiences, exposed as named environment variables in the job. This is required for token consumers (like PyPI's trusted publishers) that strictly verify a specific `aud` claim value. ```yaml title="Example" diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_aud_length.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_aud_length.yml new file mode 100644 index 000000000..e5be0ee77 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_aud_length.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens at max aud length +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Aud string at exactly maxLength 256 + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_audiences.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_audiences.yml new file mode 100644 index 000000000..b41ac163b --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_audiences.yml @@ -0,0 +1,16 @@ +version: "v1.0" +name: OIDC tokens at max audiences +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Aud list at exactly maxItems 8 + oidc_tokens: + PYPI_OIDC_TOKEN: + aud: ["a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8"] + commands: + - echo hi diff --git a/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_tokens.yml b/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_tokens.yml new file mode 100644 index 000000000..bd9807874 --- /dev/null +++ b/plumber/spec/test/pipelines/v1.0-oidc_tokens_at_max_tokens.yml @@ -0,0 +1,46 @@ +version: "v1.0" +name: OIDC tokens at max tokens +agent: + machine: + type: e1-standard-2 + os_image: ubuntu1604-minimal +blocks: + - name: Test + task: + jobs: + - name: Exactly 16 tokens + oidc_tokens: + T01: + aud: pypi + T02: + aud: pypi + T03: + aud: pypi + T04: + aud: pypi + T05: + aud: pypi + T06: + aud: pypi + T07: + aud: pypi + T08: + aud: pypi + T09: + aud: pypi + T10: + aud: pypi + T11: + aud: pypi + T12: + aud: pypi + T13: + aud: pypi + T14: + aud: pypi + T15: + aud: pypi + T16: + aud: pypi + commands: + - echo hi diff --git a/secrethub/test/secrethub/open_id_connect/jwt_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_test.exs index 169479cc9..1c77a4f93 100644 --- a/secrethub/test/secrethub/open_id_connect/jwt_test.exs +++ b/secrethub/test/secrethub/open_id_connect/jwt_test.exs @@ -79,11 +79,14 @@ defmodule Secrethub.OpenIDConnect.JWTTest do test "uses single string when audience is a single-element list (RFC 7519 convention)" do req = base_req(%{audience: ["pypi"]}) + domain = Application.fetch_env!(:secrethub, :domain) + expected_iss = "https://#{req.org_username}.#{domain}" assert {:ok, token} = JWT.generate_and_sign(req) assert {true, jwt, _} = JWT.verify(token) assert Map.get(jwt.fields, "aud") == "pypi" + assert Map.get(jwt.fields, "iss") == expected_iss end test "uses JSON array when audience is a multi-element list" do @@ -105,13 +108,17 @@ defmodule Secrethub.OpenIDConnect.JWTTest do :ok end - test "preserves audience override when aud is allowlisted" do + test "preserves audience override when aud is allowlisted and strips non-allowlisted claims" do req = base_req(%{audience: ["pypi"]}) # Configure the JWT filter to allowlist `aud` (and the other claims used # by build_oidc_claims that we want surfaced for the test). When the # :open_id_connect_filter feature flag is enabled, only allowlisted # claims survive into the signed token. + # + # Deliberately omit `sub` from the allowlist to prove the filter actually + # ran: a regression that turned the filter into a no-op would leave `sub` + # in the JWT and fail the refute below. with_mock(JWTConfiguration, [], get_org_config: fn _org_id -> {:ok, @@ -119,7 +126,6 @@ defmodule Secrethub.OpenIDConnect.JWTTest do claims: [ %{"name" => "aud", "is_active" => true}, %{"name" => "iss", "is_active" => true}, - %{"name" => "sub", "is_active" => true}, %{"name" => "exp", "is_active" => true}, %{"name" => "iat", "is_active" => true}, %{"name" => "nbf", "is_active" => true} @@ -131,6 +137,37 @@ defmodule Secrethub.OpenIDConnect.JWTTest do assert {true, jwt, _} = JWT.verify(token) assert Map.get(jwt.fields, "aud") == "pypi" + # Proves the filter ran: `sub` is in build_oidc_claims but not in the + # allowlist above, so it must have been stripped. + refute Map.has_key?(jwt.fields, "sub") + end + end + + test "strips audience override when aud is not allowlisted" do + req = base_req(%{audience: ["pypi"]}) + + # Allowlist excludes `aud`. The filter strips any non-allowlisted claim + # unconditionally (see JWTFilter._filter_claims/3), so even though the + # user requested a custom audience, `aud` is dropped from the signed + # token. + with_mock(JWTConfiguration, [], + get_org_config: fn _org_id -> + {:ok, + %{ + claims: [ + %{"name" => "iss", "is_active" => true}, + %{"name" => "sub", "is_active" => true}, + %{"name" => "exp", "is_active" => true}, + %{"name" => "iat", "is_active" => true}, + %{"name" => "nbf", "is_active" => true} + ] + }} + end + ) do + assert {:ok, token} = JWT.generate_and_sign(req) + assert {true, jwt, _} = JWT.verify(token) + + refute Map.has_key?(jwt.fields, "aud") end end end From cab58781ffb8068fc07c5e56c72777fd039a4e10 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 23:23:38 -0400 Subject: [PATCH 11/12] test(plumber): handle unset env vars in GlobalJobConfig on_exit The on_exit callback called System.put_env/2 with the value returned by System.get_env/1, which is nil when the env var was unset before the test ran. System.put_env/2 has no clause for nil and crashes. Replace the bare put_env calls with a restore_env/2 helper that calls System.delete_env/1 when the saved value was nil. The test now passes regardless of host env state. --- .../ppl/test/definition_reviser/global_job_config_test.exs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plumber/ppl/test/definition_reviser/global_job_config_test.exs b/plumber/ppl/test/definition_reviser/global_job_config_test.exs index faac99258..c8bea1d00 100644 --- a/plumber/ppl/test/definition_reviser/global_job_config_test.exs +++ b/plumber/ppl/test/definition_reviser/global_job_config_test.exs @@ -31,8 +31,8 @@ defmodule Ppl.DefinitionReviser.BlocksReviser.GlobalJobConfig.Test do Test.Support.GrpcServerHelper.setup_service_url(@url_env_name_2, project_port) on_exit(fn -> - System.put_env(@url_env_name, old_artifact_url) - System.put_env(@url_env_name_2, old_project_url) + restore_env(@url_env_name, old_artifact_url) + restore_env(@url_env_name_2, old_project_url) end) Test.Helpers.truncate_db() @@ -133,4 +133,7 @@ defmodule Ppl.DefinitionReviser.BlocksReviser.GlobalJobConfig.Test do } } end + + defp restore_env(name, nil), do: System.delete_env(name) + defp restore_env(name, value), do: System.put_env(name, value) end From ad04d819a84f009941555f4adf193e5e0d3eac09 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Fri, 1 May 2026 23:34:04 -0400 Subject: [PATCH 12/12] fix(secrethub): assert user audience override is not propagated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous "strips audience" test asserted `aud` was absent from the final JWT after the filter ran. CI revealed that Joken's default config injects a placeholder `aud` value ("Joken") when the claims map lacks one, so `aud` is present after signing — just not the user's override. The security-relevant property is "user override is suppressed", not "aud key is absent." Updated the assertion to refute equality with the override and clarified the test name and docstring. --- .../test/secrethub/open_id_connect/jwt_test.exs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/secrethub/test/secrethub/open_id_connect/jwt_test.exs b/secrethub/test/secrethub/open_id_connect/jwt_test.exs index 1c77a4f93..314033a3b 100644 --- a/secrethub/test/secrethub/open_id_connect/jwt_test.exs +++ b/secrethub/test/secrethub/open_id_connect/jwt_test.exs @@ -143,13 +143,18 @@ defmodule Secrethub.OpenIDConnect.JWTTest do end end - test "strips audience override when aud is not allowlisted" do + test "user audience override is not present when aud is not allowlisted" do req = base_req(%{audience: ["pypi"]}) - # Allowlist excludes `aud`. The filter strips any non-allowlisted claim - # unconditionally (see JWTFilter._filter_claims/3), so even though the - # user requested a custom audience, `aud` is dropped from the signed - # token. + # Allowlist excludes `aud`. JWTFilter._filter_claims/3 strips any + # non-allowlisted claim unconditionally, so the user's "pypi" override + # is dropped from the claims map before signing. + # + # Note: Joken's default config injects its own placeholder `aud` value + # ("Joken") when the claims map lacks one, so the final JWT will still + # have an `aud` field — but it will *not* be the user-supplied "pypi". + # The security-relevant property is "user override is suppressed", not + # "aud key is absent." with_mock(JWTConfiguration, [], get_org_config: fn _org_id -> {:ok, @@ -167,7 +172,7 @@ defmodule Secrethub.OpenIDConnect.JWTTest do assert {:ok, token} = JWT.generate_and_sign(req) assert {true, jwt, _} = JWT.verify(token) - refute Map.has_key?(jwt.fields, "aud") + refute Map.get(jwt.fields, "aud") == "pypi" end end end