diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index cb4f50307..5008bcb52 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -177,11 +177,13 @@ jobs:
- test: TestPluginConfig
system_plugins: sftp
# Installs no cloud plugin: the oidc-credential fixtures (a stub
- # broker and a resource plugin that exchanges the token it is
- # handed) are built from this repo and staged by `make test-e2e`
- # itself. It does need AWS credentials — the broker reads its
- # signing key from Secrets Manager and the plugin calls
- # sts:AssumeRoleWithWebIdentity against the standing issuer.
+ # broker, a resource plugin that exchanges the token it is handed,
+ # and a stub hosted auth plugin) are built from this repo and staged
+ # by `make test-e2e` itself. It does need AWS credentials, and the
+ # `e2e-test` shared-config profile written above: `formae connect`
+ # provisions the role with it, the broker reads its signing key from
+ # Secrets Manager, and the plugin calls sts:AssumeRoleWithWebIdentity
+ # against the standing issuer.
- test: TestOidcCredential
steps:
@@ -219,10 +221,19 @@ jobs:
output-credentials: true
- name: Add profile credentials to ~/.aws/credentials
+ # The session token is not optional: these are temporary credentials
+ # from an assumed role, and the key pair alone is refused with
+ # InvalidClientTokenId. It went unnoticed while nothing read the
+ # profile as a profile — the action also exports the three values as
+ # job env vars, the SDK's default chain prefers those over shared
+ # config, and the access test below passes no --profile. `formae
+ # connect --profile-aws` forces the shared profile, so it is the first
+ # thing here that authenticates through this file.
run: |
mkdir -p ~/.aws
aws configure set aws_access_key_id ${{ steps.creds.outputs.aws-access-key-id }} --profile e2e-test
aws configure set aws_secret_access_key ${{ steps.creds.outputs.aws-secret-access-key }} --profile e2e-test
+ aws configure set aws_session_token ${{ steps.creds.outputs.aws-session-token }} --profile e2e-test
aws configure set region us-west-2 --profile e2e-test
- name: Test AWS Access
@@ -238,6 +249,18 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ # Only the oidc-credential entry federates into GCP, and only it pays
+ # for the sign-in. Gated on the secret as well as the test, so a fork
+ # without credentials reports the GCP half as skipped rather than red.
+ - name: Configure GCP Credentials
+ if: ${{ matrix.test == 'TestOidcCredential' && env.GCP_WORKLOAD_IDENTITY_PROVIDER != '' }}
+ env:
+ GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
+ uses: google-github-actions/auth@v3
+ with:
+ workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
+ service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
+
- name: Install system plugins via orbital
if: ${{ matrix.system_plugins }}
run: |
@@ -327,6 +350,13 @@ jobs:
env:
AWS_PROFILE: e2e-test
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ # The project the oidc-credential suite federates into. Its own
+ # project, not the one the GCP plugin's suites use: provx fixes the
+ # workload identity pool and provider ids per project and refuses to
+ # converge a provider trusting a different issuer, so a project
+ # already carrying the production-issuer connection cannot carry the
+ # e2e's as well. Absent, the GCP half skips.
+ E2E_GCP_PROJECT: ${{ secrets.GCP_PROJECT_ID }}
# Unique per run+attempt so fixtures can name globally-scoped cloud
# resources (e.g. CloudFront KeyValueStore/Function, which aws-nuke
# does not clean) without colliding across reruns or concurrent jobs.
diff --git a/Makefile b/Makefile
index 00449f427..344013a6f 100644
--- a/Makefile
+++ b/Makefile
@@ -267,20 +267,23 @@ test-e2e: build stage-oidc-fixtures
E2E_FORMAE_BINARY=$(CURDIR)/dist/e2e/bin/formae \
E2E_OIDC_PLUGIN_DIR=$(OIDC_STAGE_DIR) \
E2E_OIDC_PLUGIN_DIR_NO_BROKER=$(OIDC_STAGE_DIR_NO_BROKER) \
+ E2E_OIDC_AUTH_PLUGIN_DIR=$(OIDC_STAGE_DIR_AUTH) \
go test -C ./tests/e2e/go -tags=e2e -timeout 30m -v ./... $(E2E_RUN_FLAGS)
-# The hermetic oidc-credential fixtures: a stub credential broker and a resource
-# plugin that echoes the token the broker mints. Staged into two plugin trees
-# the e2e agent can be pointed at — one where the broker sits beside the echo
-# plugin, one where the echo plugin is alone — in the layout plugin discovery
-# expects (
//v/ beside the manifest).
+# The oidc-credential fixtures: a stub credential broker, a resource plugin that
+# echoes the token the broker mints, and a stub hosted auth plugin. Staged into
+# three plugin trees — one where the broker sits beside the echo plugin, one
+# where the echo plugin is alone, and one holding only the auth plugin, which the
+# CLI reads rather than the agent — in the layout plugin discovery expects
+# (//v/ beside the manifest).
OIDC_FIXTURE_DIR := $(CURDIR)/tests/e2e/go/fixtures
OIDC_STAGE_DIR := $(CURDIR)/dist/e2e/oidc-plugins
OIDC_STAGE_DIR_NO_BROKER := $(CURDIR)/dist/e2e/oidc-plugins-no-broker
+OIDC_STAGE_DIR_AUTH := $(CURDIR)/dist/e2e/oidc-auth-plugin
stage-oidc-fixtures:
@echo "Staging e2e oidc-credential fixtures..."
- rm -rf $(OIDC_STAGE_DIR) $(OIDC_STAGE_DIR_NO_BROKER)
+ rm -rf $(OIDC_STAGE_DIR) $(OIDC_STAGE_DIR_NO_BROKER) $(OIDC_STAGE_DIR_AUTH)
mkdir -p $(OIDC_STAGE_DIR)/oidc-credential-stub/v0.0.1
go build -C $(OIDC_FIXTURE_DIR)/oidc-credential-stub \
-o $(OIDC_STAGE_DIR)/oidc-credential-stub/v0.0.1/oidc-credential-stub .
@@ -295,6 +298,14 @@ stage-oidc-fixtures:
$(OIDC_STAGE_DIR)/oidc-echo/v0.0.1/schema/pkl
mkdir -p $(OIDC_STAGE_DIR_NO_BROKER)
cp -R $(OIDC_STAGE_DIR)/oidc-echo $(OIDC_STAGE_DIR_NO_BROKER)/oidc-echo
+ # Installed under the name `oidc` rather than the fixture directory's name:
+ # the CLI resolves an auth plugin by the `type` its profile's auth block
+ # names, and the connect gate admits only that one.
+ mkdir -p $(OIDC_STAGE_DIR_AUTH)/oidc/v0.0.1
+ go build -C $(OIDC_FIXTURE_DIR)/oidc-auth-stub \
+ -o $(OIDC_STAGE_DIR_AUTH)/oidc/v0.0.1/oidc .
+ cp $(OIDC_FIXTURE_DIR)/oidc-auth-stub/formae-plugin.pkl \
+ $(OIDC_STAGE_DIR_AUTH)/oidc/v0.0.1/formae-plugin.pkl
## test-property: Run property tests (FullChaos 100 iterations, others 50)
test-property:
diff --git a/go.mod b/go.mod
index f1dd83165..1e32b2183 100644
--- a/go.mod
+++ b/go.mod
@@ -62,7 +62,7 @@ require (
github.com/platform-engineering-labs/formae/pkg/plugin v0.0.0-00010101000000-000000000000
github.com/platform-engineering-labs/formae/tests/testcontrol v0.0.0-00010101000000-000000000000
github.com/platform-engineering-labs/jsonpatch v0.0.0-20260620044942-701436c7758c
- github.com/platform-engineering-labs/oox/provx v0.0.0-20260825164708-d2e52420e91b // re-pin to a real tag at release time
+ github.com/platform-engineering-labs/oox/provx v0.0.0-20260828004425-f2ab99b17591 // re-pin to a real tag at release time
github.com/platform-engineering-labs/orbital v0.2.5
github.com/posthog/posthog-go v1.6.3
github.com/pressly/goose/v3 v3.26.0
diff --git a/go.sum b/go.sum
index 7d24cd037..e0c43b738 100644
--- a/go.sum
+++ b/go.sum
@@ -467,6 +467,8 @@ github.com/platform-engineering-labs/oox/gcpname v0.0.0-20260825170105-3bd97cb18
github.com/platform-engineering-labs/oox/gcpname v0.0.0-20260825170105-3bd97cb18d15/go.mod h1:wytVDQEOgo/0PLmMj0nOnisBzzqwemanNy9gpueH9Ik=
github.com/platform-engineering-labs/oox/provx v0.0.0-20260825164708-d2e52420e91b h1:Qh9/9eWqFTK8w/yYHooGVOuRU5ep17Ws7iRVJvthRDg=
github.com/platform-engineering-labs/oox/provx v0.0.0-20260825164708-d2e52420e91b/go.mod h1:6dTkhIgTXyXHctVmK16kkM2qnfaX/FOT0aWA+9oeJtM=
+github.com/platform-engineering-labs/oox/provx v0.0.0-20260828004425-f2ab99b17591 h1:wr3lRnfa/LRXia1C/VYcqMU4Tkjo4dnSSD+EAiGgRkA=
+github.com/platform-engineering-labs/oox/provx v0.0.0-20260828004425-f2ab99b17591/go.mod h1:6dTkhIgTXyXHctVmK16kkM2qnfaX/FOT0aWA+9oeJtM=
github.com/platform-engineering-labs/orbital v0.2.5 h1:7rihasWnR68ORytqLubR5WMJchv/mxoWSCB7MqaB9tU=
github.com/platform-engineering-labs/orbital v0.2.5/go.mod h1:wwVPqOmW5RO78dDzL/G5CN1Ioao0P/aUsOZVrPVx64s=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
diff --git a/internal/cli/connect/gcpprovision.go b/internal/cli/connect/gcpprovision.go
index 9f6ef4fdf..421235c8a 100644
--- a/internal/cli/connect/gcpprovision.go
+++ b/internal/cli/connect/gcpprovision.go
@@ -20,15 +20,15 @@ type gcpProvisioner interface {
}
// newGCPProvisioner is the seam tests substitute. Production constructs provx
-// with the server-produced subject verbatim: connect has no naming knowledge
-// of its own, and inventing a subject here would produce trust the issuer
-// never mints for.
+// with the server-produced subject and the pinned issuer verbatim: connect has
+// no naming knowledge of its own, and inventing either here would produce
+// trust the issuer never mints for.
var newGCPProvisioner = func(ctx context.Context, project, subject, issuer string) (gcpProvisioner, error) {
tenantID, installationID, err := splitSubject(subject)
if err != nil {
return nil, err
}
- return provxgcp.New(ctx, slog.Default(), project, tenantID, installationID)
+ return provxgcp.New(ctx, slog.Default(), project, tenantID, installationID, issuer)
}
// provisionGCP converges the project's federation and reports what it created.
diff --git a/tests/e2e/config/nuke-config.yaml b/tests/e2e/config/nuke-config.yaml
index b51d9ac76..07bcb96e5 100644
--- a/tests/e2e/config/nuke-config.yaml
+++ b/tests/e2e/config/nuke-config.yaml
@@ -154,12 +154,16 @@ presets:
# resource types are protected by default (allowlist semantics).
#
# The same allowlist is what keeps the standing oidc-credential fixtures
- # alive: the static issuer bucket, its IAM OIDC provider, the role the
- # e2e assumes, and the Secrets Manager secret holding the signing key are
- # all named e2e-oidc-*, contain no "formae-e2e", and are therefore never
- # removed. They are provisioned once, out of band, from the forma in
- # tests/e2e/config/oidc-standing/. Do not turn this preset into a
- # denylist without re-homing them first.
+ # alive: the static issuer bucket, its IAM OIDC provider, and the Secrets
+ # Manager secret holding the signing key are all named e2e-oidc-*, contain
+ # no "formae-e2e", and are therefore never removed. They are provisioned
+ # once, out of band, from the forma in tests/e2e/config/oidc-standing/. Do
+ # not turn this preset into a denylist without re-homing them first.
+ #
+ # The role that suite's token is exchanged for is NOT standing: `formae
+ # connect` provisions it per run as formae-e2e-oidc-connect-role, which is
+ # inside the allowlist and inside the leftover-role purge above, so a run
+ # that dies before its teardown is reclaimed rather than accumulating.
filters:
__global__:
- property: Name
diff --git a/tests/e2e/config/oidc-standing/oidc_standing.pkl b/tests/e2e/config/oidc-standing/oidc_standing.pkl
index a9940d3d0..69818016a 100644
--- a/tests/e2e/config/oidc-standing/oidc_standing.pkl
+++ b/tests/e2e/config/oidc-standing/oidc_standing.pkl
@@ -4,7 +4,16 @@
* SPDX-License-Identifier: FSL-1.1-ALv2
*/
-/// The standing resources the oidc-credential e2e exchanges a token against.
+/// The standing resources the oidc-credential e2e mints and exchanges a token
+/// against: the static issuer, its published keys, and the IAM identity
+/// provider that trusts it.
+///
+/// The role the token is exchanged for is deliberately NOT here. `formae
+/// connect` provisions it on every run, from the coordinates the e2e's stub
+/// control plane produces, which is how the suite proves the connect path
+/// instead of assuming its output. The identity provider stays standing
+/// because it is account-global and shared, and because connect validates and
+/// reuses an existing one rather than needing to create it.
///
/// These are NOT created by the test run: the e2e assumes they already exist
/// in the e2e account, and the aws-nuke pre-cleanup preserves them (their
@@ -33,7 +42,6 @@ import "@formae/formae.pkl"
import "@aws/aws.pkl"
import "@aws/iam/oidcprovider.pkl"
-import "@aws/iam/role.pkl"
import "@aws/s3/bucket.pkl"
import "@aws/s3/bucketpolicy.pkl"
import "@aws/s3/object.pkl"
@@ -57,11 +65,12 @@ local issuerUrl = "https://\(issuerHost)"
/// Pinned in the JWKS and in the header of every minted token.
local keyId = "e2e-oidc-key-1"
-/// The only subject the role trusts, and the only audience it accepts.
-local subject = "e2e-oidc-subject"
+/// The audience the provider accepts. The subject the role trusts is not
+/// here: the role is provisioned per run by `formae connect`, from the subject
+/// the e2e's stub control plane hands it, which is what makes the e2e prove
+/// the connect path rather than assume it.
local audience = "sts.amazonaws.com"
-local assumeRoleName = "e2e-oidc-assume-role"
local secretName = "e2e-oidc-signing-key"
/// SHA-1 fingerprint of Amazon Root CA 1, which terminates the chain for every
@@ -152,33 +161,6 @@ local issuerProvider = new oidcprovider.OIDCProvider {
thumbprintList = new Listing { issuerThumbprint }
}
-/// The role the e2e assumes. It grants nothing: what the test proves is that
-/// STS accepts the token and hands back credentials, not what they can do.
-local assumeRole = new role.Role {
- label = "e2e-oidc-assume-role"
- roleName = assumeRoleName
- description = "Assumed by the oidc-credential e2e via web identity"
- maxSessionDuration = 3600
- assumeRolePolicyDocument = new Dynamic {
- ["Version"] = "2012-10-17"
- ["Statement"] = new Listing {
- new Dynamic {
- ["Effect"] = "Allow"
- ["Principal"] = new Dynamic {
- ["Federated"] = "arn:aws:iam::\(accountId):oidc-provider/\(issuerHost)"
- }
- ["Action"] = "sts:AssumeRoleWithWebIdentity"
- ["Condition"] = new Dynamic {
- ["StringEquals"] = new Dynamic {
- ["\(issuerHost):sub"] = subject
- ["\(issuerHost):aud"] = audience
- }
- }
- }
- }
- }
-}
-
/// The private half, read from the agent's environment so it never lands in
/// the tree. Opaque and set-once: formae writes it at create and thereafter
/// neither reads it back nor diffs it.
@@ -192,7 +174,7 @@ local signingKey = new secret.Secret {
forma {
new formae.Stack {
label = "e2e-oidc-standing"
- description = "Standing issuer, provider, role and key for the oidc-credential e2e"
+ description = "Standing issuer, provider and key for the oidc-credential e2e"
}
new formae.Target {
@@ -207,6 +189,5 @@ forma {
discoveryDocument
jwks
issuerProvider
- assumeRole
signingKey
}
diff --git a/tests/e2e/go/connect.go b/tests/e2e/go/connect.go
new file mode 100644
index 000000000..77d0d1b62
--- /dev/null
+++ b/tests/e2e/go/connect.go
@@ -0,0 +1,415 @@
+// © 2026 Platform Engineering Labs Inc.
+//
+// SPDX-License-Identifier: FSL-1.1-ALv2
+
+//go:build e2e
+
+package e2e_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+)
+
+// The hosted half of a `formae connect` run, stubbed.
+//
+// Connect cannot reach a cloud account without one. Every path opens a session
+// against the control plane before it touches AWS: it resolves a hosted
+// profile, drives that profile's auth plugin for a bearer, reads the subject,
+// role name and issuer it must provision for, and reports the role it made
+// back. This file stands up that control plane, the profile that addresses it,
+// and the environment that points the CLI at both. Everything on the AWS side
+// of the run — the caller check, the OIDC provider, the role, its trust policy
+// — is real.
+
+// oidcAuthStubBearer is the credential the staged auth plugin hands back.
+// Its source of truth is tests/e2e/go/fixtures/oidc-auth-stub; the stub
+// control plane compares against it, so a request that did not come through
+// the auth plugin is refused rather than served.
+const oidcAuthStubBearer = "Bearer e2e-oidc-connect-token"
+
+// testRunIDEnv carries a value unique to one workflow run and attempt. The
+// e2e workflow sets it; a developer run has none.
+const testRunIDEnv = "FORMAE_TEST_RUN_ID"
+
+// runFingerprint is this run's identity, computed once per test process.
+//
+// Everything connect provisions is named from it, which is what makes a green
+// run mean something. With a fixed identity, the second run onwards exchanges
+// against trust the first one left behind: a connect that had silently stopped
+// provisioning would keep passing, and so would one that failed to repair
+// state some other actor had broken. Naming the identity per run makes the
+// exchange attributable to the invocation under test.
+//
+// It falls back to the clock rather than failing, so the suite still runs on a
+// developer machine — where two runs sharing an identity is the same hazard,
+// one account down.
+var runFingerprint = sync.OnceValue(func() string {
+ seed := os.Getenv(testRunIDEnv)
+ if seed == "" {
+ seed = fmt.Sprintf("local-%d", time.Now().UnixNano())
+ }
+ sum := sha256.Sum256([]byte(seed))
+ return hex.EncodeToString(sum[:])
+})
+
+// ConnectInstallationID is the installation a connect run addresses. The
+// profile loader requires 27 base62 characters, and hex satisfies that.
+// Nothing resolves it: the stub answers for whichever installation the run
+// names, and it is per-run so the subject built from it is too.
+func ConnectInstallationID() string { return runFingerprint()[:27] }
+
+// ConnectRunSuffix is the short discriminator that per-run cloud resource
+// names carry.
+func ConnectRunSuffix() string { return runFingerprint()[:12] }
+
+// oidcAuthPluginDirEnv names the plugin tree holding the stub auth plugin,
+// staged by `make test-e2e`.
+const oidcAuthPluginDirEnv = "E2E_OIDC_AUTH_PLUGIN_DIR"
+
+// connectSetup is the coordinates the control plane produces and connect
+// provisions against, verbatim: they travel from this struct into the role's
+// name and its trust policy without connect inventing anything of its own.
+type connectSetup struct {
+ CloudSubject string `json:"cloudSubject"`
+ CloudRoleName string `json:"cloudRoleName"`
+ Issuer string `json:"issuer"`
+}
+
+// stubRegistration is one registration the stub control plane received.
+//
+// The coordinate fields are pointers because the control plane's real schema
+// is a discriminated union that admits exactly one shape per cloud and rejects
+// a field belonging to another. Absent and present-but-empty are therefore
+// different answers, and a plain string cannot tell them apart — which would
+// let a test claim the wrong coordinate never crossed the wire when in fact it
+// crossed as "".
+type stubRegistration struct {
+ Cloud string `json:"cloud"`
+ Account string `json:"account"`
+ RoleArn *string `json:"roleArn"`
+ WorkloadIdentityProvider *string `json:"workloadIdentityProvider"`
+}
+
+// Coordinate returns the trust coordinate this registration carries, and
+// whether the field was present at all.
+func (r stubRegistration) Coordinate() (string, bool) {
+ if r.Cloud == "gcp" {
+ if r.WorkloadIdentityProvider == nil {
+ return "", false
+ }
+ return *r.WorkloadIdentityProvider, true
+ }
+ if r.RoleArn == nil {
+ return "", false
+ }
+ return *r.RoleArn, true
+}
+
+// ForeignCoordinatePresent reports whether the registration carried the field
+// belonging to a different cloud, which the real schema would reject.
+func (r stubRegistration) ForeignCoordinatePresent() bool {
+ if r.Cloud == "gcp" {
+ return r.RoleArn != nil
+ }
+ return r.WorkloadIdentityProvider != nil
+}
+
+// connectStub is a running stub control plane.
+type connectStub struct {
+ URL string
+
+ mu sync.Mutex
+ registrations []stubRegistration
+}
+
+// Registrations returns what the stub received, as a copy: a test reads it
+// after the run rather than racing the handler for it.
+func (s *connectStub) Registrations() []stubRegistration {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]stubRegistration(nil), s.registrations...)
+}
+
+// StartConnectStub serves the two endpoints a connect run calls, on loopback
+// http — which the CLI's origin rule admits for exactly this reason. Only the
+// endpoints a successful run reaches are served: anything else 404s, which
+// surfaces an unexpected call as a failure instead of a plausible answer.
+func StartConnectStub(t *testing.T, setup connectSetup) *connectStub {
+ t.Helper()
+
+ stub := &connectStub{}
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("GET /api/v1/installations/{installation}/cloud-connection-setup",
+ func(w http.ResponseWriter, r *http.Request) {
+ if !stubAuthorized(t, w, r) || !stubAddressedCorrectly(t, w, r) {
+ return
+ }
+ writeStubJSON(t, w, http.StatusOK, setup)
+ })
+
+ mux.HandleFunc("POST /api/v1/installations/{installation}/cloud-connections",
+ func(w http.ResponseWriter, r *http.Request) {
+ if !stubAuthorized(t, w, r) || !stubAddressedCorrectly(t, w, r) {
+ return
+ }
+ var registration stubRegistration
+ if err := decodeStrict(r.Body, ®istration); err != nil {
+ t.Errorf("stub control plane: registration body: %v", err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ stub.mu.Lock()
+ stub.registrations = append(stub.registrations, registration)
+ stub.mu.Unlock()
+ // The created row is the registration echoed back, which the CLI
+ // does not parse; the status is the whole of the answer.
+ writeStubJSON(t, w, http.StatusCreated, registration)
+ })
+
+ server := httptest.NewServer(mux)
+ t.Cleanup(server.Close)
+ stub.URL = server.URL
+ return stub
+}
+
+// stubAuthorized refuses anything that did not arrive with the credential the
+// auth plugin mints. Sending 401 rather than failing the test outright keeps
+// the CLI's own refusal the thing under test.
+func stubAuthorized(t *testing.T, w http.ResponseWriter, r *http.Request) bool {
+ t.Helper()
+
+ if r.Header.Get("Authorization") != oidcAuthStubBearer {
+ writeStubJSON(t, w, http.StatusUnauthorized, map[string]any{
+ "error": map[string]any{"code": "unauthorized"},
+ })
+ return false
+ }
+ return true
+}
+
+// stubAddressedCorrectly holds the run to the installation it configured. The
+// route variable is the only place the profile's installation id reaches the
+// control plane, so serving any id would let a run that addressed the wrong
+// installation pass.
+func stubAddressedCorrectly(t *testing.T, w http.ResponseWriter, r *http.Request) bool {
+ t.Helper()
+
+ if got := r.PathValue("installation"); got != ConnectInstallationID() {
+ t.Errorf("stub control plane: request addressed installation %q, want %q", got, ConnectInstallationID())
+ writeStubJSON(t, w, http.StatusNotFound, map[string]any{
+ "error": map[string]any{"code": "not_found"},
+ })
+ return false
+ }
+ return true
+}
+
+// decodeStrict reads exactly one JSON document into v and refuses anything
+// the real control plane would: an unknown field, or trailing content after
+// the object.
+func decodeStrict(r io.Reader, v any) error {
+ dec := json.NewDecoder(r)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(v); err != nil {
+ return fmt.Errorf("not a JSON object of the expected shape: %w", err)
+ }
+ if err := dec.Decode(new(json.RawMessage)); err != io.EOF {
+ return fmt.Errorf("carries content after the JSON object")
+ }
+ return nil
+}
+
+func writeStubJSON(t *testing.T, w http.ResponseWriter, status int, body any) {
+ t.Helper()
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(body); err != nil {
+ t.Errorf("stub control plane: writing the response: %v", err)
+ }
+}
+
+// hostedConnectConfig is the profile a connect run is pointed at with
+// --config. It is the shape `formae login` writes, plus a pluginDir so the
+// staged auth plugin is the one resolved: the CLI matches an auth plugin by
+// the type the auth block names, and the connect gate admits only "oidc".
+//
+// endpoint addresses the installation's agent and is never dialled by a
+// connect run, but it has to parse as an https origin, so it names one that
+// cannot resolve rather than one that could.
+const hostedConnectConfig = `/*
+ * Auto-generated e2e test configuration
+ */
+
+amends "formae:/Config.pkl"
+
+pluginDir = %q
+
+cli {
+ connection = new Hosted {
+ endpoint = "https://e2e-oidc-connect.invalid"
+ installation = %q
+ auth = new Dynamic {
+ type = "oidc"
+ role = "cli"
+ issuer = %q
+ clientId = "formae-cli"
+ scopes = "openid profile email offline_access"
+ }
+ }
+}
+`
+
+// WriteHostedConnectConfig writes the profile and returns its path. issuer is
+// the login issuer the profile's auth block names, which the gate requires to
+// be the platform's own: the stub serves both, so it is the stub's origin.
+func WriteHostedConnectConfig(t *testing.T, dir, authPluginDir, issuer string) string {
+ t.Helper()
+
+ path := filepath.Join(dir, "connect-config.pkl")
+ content := fmt.Sprintf(hostedConnectConfig, authPluginDir, ConnectInstallationID(), issuer)
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("failed to write the connect config: %v", err)
+ }
+ return path
+}
+
+// ConnectEnv is the environment a connect run needs, on top of the test
+// process's own.
+//
+// Two override pairs, and each half of each must be set with the other. The
+// control-plane pair aims the bearer: FORMAE_CLOUD_URL is where the request
+// goes and FORMAE_CLOUD_ISSUER is the issuer the profile's auth block must
+// name to be allowed to produce one. The connect pair pins the AWS-side trust
+// artifacts: FORMAE_CONNECT_ISSUER must equal the issuer the control plane
+// names, or the run refuses it as untrusted, and FORMAE_CONNECT_TEMPLATE_BASE
+// only rides along, since the direct-provision path fetches no template.
+func ConnectEnv(controlPlane, connectIssuer string) []string {
+ return append(os.Environ(),
+ "FORMAE_CLOUD_URL="+controlPlane,
+ "FORMAE_CLOUD_ISSUER="+controlPlane,
+ "FORMAE_CONNECT_ISSUER="+connectIssuer,
+ "FORMAE_CONNECT_TEMPLATE_BASE=https://formae-connect-templates.s3.us-east-1.amazonaws.com",
+ )
+}
+
+// RegisteredDocument is the machine-protocol document a connect run emits when
+// a registration happened.
+// Each cloud reports its own trust coordinate and omits the others, so a
+// reader takes Cloud first and then the field that cloud carries.
+type RegisteredDocument struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Phase string `json:"phase"`
+ Status string `json:"status"`
+ Cloud string `json:"cloud"`
+ Account string `json:"account"`
+ RoleArn string `json:"roleArn"`
+ WorkloadIdentityProvider string `json:"workloadIdentityProvider"`
+ Warnings []string `json:"warnings"`
+}
+
+// RunConnect runs the connect command with the given environment and returns
+// the registration document it emitted. Machine output is the surface a test
+// can assert on: it is a declared protocol rather than rendered prose.
+func RunConnect(t *testing.T, bin string, env []string, args ...string) RegisteredDocument {
+ t.Helper()
+
+ full := append([]string{"connect"}, args...)
+ full = append(full, "--output-consumer", "machine", "--output-schema", "json")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, bin, full...)
+ cmd.Env = env
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ stdout, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("formae connect failed: %v\nargs: %v\nstdout: %s\nstderr: %s",
+ err, full, string(stdout), stderr.String())
+ }
+
+ var doc RegisteredDocument
+ if err := json.Unmarshal(stdout, &doc); err != nil {
+ t.Fatalf("failed to parse the connect document: %v\nstdout: %s", err, string(stdout))
+ }
+ return doc
+}
+
+// DeleteIAMRole removes a role connect provisioned, along with the permission
+// posture it attached: IAM refuses to delete a role that still carries
+// policies, so they go first. Every step tolerates an absent target, because
+// cleanup also runs after a failure that got partway.
+func DeleteIAMRole(t *testing.T, roleName string) {
+ t.Helper()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-west-2"))
+ if err != nil {
+ t.Errorf("cleanup: failed to load AWS config: %v", err)
+ return
+ }
+ client := iam.NewFromConfig(cfg)
+
+ attached, err := client.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{
+ RoleName: aws.String(roleName),
+ })
+ if err != nil {
+ if strings.Contains(err.Error(), "NoSuchEntity") {
+ return
+ }
+ t.Errorf("cleanup: listing attached policies of %q: %v", roleName, err)
+ return
+ }
+ for _, policy := range attached.AttachedPolicies {
+ if _, err := client.DetachRolePolicy(ctx, &iam.DetachRolePolicyInput{
+ RoleName: aws.String(roleName),
+ PolicyArn: policy.PolicyArn,
+ }); err != nil {
+ t.Errorf("cleanup: detaching %s from %q: %v", aws.ToString(policy.PolicyArn), roleName, err)
+ }
+ }
+
+ inline, err := client.ListRolePolicies(ctx, &iam.ListRolePoliciesInput{
+ RoleName: aws.String(roleName),
+ })
+ if err != nil {
+ t.Errorf("cleanup: listing inline policies of %q: %v", roleName, err)
+ return
+ }
+ for _, name := range inline.PolicyNames {
+ if _, err := client.DeleteRolePolicy(ctx, &iam.DeleteRolePolicyInput{
+ RoleName: aws.String(roleName),
+ PolicyName: aws.String(name),
+ }); err != nil {
+ t.Errorf("cleanup: deleting inline policy %s of %q: %v", name, roleName, err)
+ }
+ }
+
+ if _, err := client.DeleteRole(ctx, &iam.DeleteRoleInput{RoleName: aws.String(roleName)}); err != nil {
+ t.Errorf("cleanup: deleting role %q: %v", roleName, err)
+ }
+}
diff --git a/tests/e2e/go/fixtures/oidc-auth-stub/formae-plugin.pkl b/tests/e2e/go/fixtures/oidc-auth-stub/formae-plugin.pkl
new file mode 100644
index 000000000..c48860385
--- /dev/null
+++ b/tests/e2e/go/fixtures/oidc-auth-stub/formae-plugin.pkl
@@ -0,0 +1,16 @@
+/*
+ * © 2026 Platform Engineering Labs Inc.
+ *
+ * SPDX-License-Identifier: FSL-1.1-ALv2
+ */
+
+/// Installed as `oidc`, not as this directory's name: the CLI matches an auth
+/// plugin by the `type` its profile's auth block names, and the connect gate
+/// admits only "oidc". The staging step in the Makefile lays it out under
+/// that name.
+name = "oidc"
+type = "auth"
+version = "0.0.1"
+summary = "Stub hosted auth plugin for the e2e suite"
+license = "FSL-1.1-ALv2"
+category = "auth"
diff --git a/tests/e2e/go/fixtures/oidc-auth-stub/go.mod b/tests/e2e/go/fixtures/oidc-auth-stub/go.mod
new file mode 100644
index 000000000..4e8acafba
--- /dev/null
+++ b/tests/e2e/go/fixtures/oidc-auth-stub/go.mod
@@ -0,0 +1,7 @@
+module github.com/platform-engineering-labs/formae/tests/e2e/go/fixtures/oidc-auth-stub
+
+go 1.26
+
+replace github.com/platform-engineering-labs/formae/pkg/auth => ../../../../../pkg/auth
+
+require github.com/platform-engineering-labs/formae/pkg/auth v0.0.0
diff --git a/tests/e2e/go/fixtures/oidc-auth-stub/main.go b/tests/e2e/go/fixtures/oidc-auth-stub/main.go
new file mode 100644
index 000000000..0a92b4d72
--- /dev/null
+++ b/tests/e2e/go/fixtures/oidc-auth-stub/main.go
@@ -0,0 +1,51 @@
+// © 2026 Platform Engineering Labs Inc.
+//
+// SPDX-License-Identifier: FSL-1.1-ALv2
+
+// Command oidc-auth-stub stands in for the hosted auth plugin so the e2e
+// suite can drive `formae connect` against a control plane of its own.
+//
+// It is installed under the name `oidc` because that is what the CLI looks
+// for: a hosted profile's auth block names the plugin in its `type` field,
+// and the connect gate requires that name exactly before it will send a
+// credential anywhere. The real plugin mints its bearer through an
+// interactive sign-in against a deployed issuer, which no CI run can carry
+// out; this one hands back a fixed token the e2e's stub control plane
+// recognises, so what the test exercises is the connect flow rather than the
+// sign-in behind it.
+package main
+
+import (
+ "github.com/platform-engineering-labs/formae/pkg/auth"
+)
+
+// Bearer is the credential this plugin hands back. The e2e's stub control
+// plane compares against it byte for byte, so a run that reached the control
+// plane with anything else did not come through the auth plugin.
+const Bearer = "Bearer e2e-oidc-connect-token"
+
+// stub answers GetAuthHeader and nothing else. Validate is what an agent
+// would call to check an inbound credential, and no agent in this suite is
+// configured with this plugin, so the embedded base's unsupported answer is
+// the honest one.
+type stub struct {
+ auth.UnimplementedAuthPlugin
+}
+
+// Init accepts whatever the profile's auth block carries. The block is the
+// oidc plugin's CLI configuration and this stub reads nothing out of it: the
+// gate has already checked the fields that decide whether a credential may
+// be minted at all.
+func (s *stub) Init(_ *auth.InitRequest, _ *auth.InitResponse) error { return nil }
+
+// GetAuthHeader returns the fixed credential under the canonical header key.
+// The client attaches only "Authorization", so returning it under any other
+// spelling would fail closed.
+func (s *stub) GetAuthHeader(_ *auth.GetAuthHeaderRequest, resp *auth.GetAuthHeaderResponse) error {
+ resp.Headers = map[string][]string{"Authorization": {Bearer}}
+ return nil
+}
+
+func main() {
+ auth.Run(&stub{})
+}
diff --git a/tests/e2e/go/fixtures/oidc-credential-stub/main.go b/tests/e2e/go/fixtures/oidc-credential-stub/main.go
index b6ed4e2ef..2977ea671 100644
--- a/tests/e2e/go/fixtures/oidc-credential-stub/main.go
+++ b/tests/e2e/go/fixtures/oidc-credential-stub/main.go
@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"log"
+ "os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
@@ -38,9 +39,12 @@ const (
// it from the token header to pick the public key it verifies with.
keyID = "e2e-oidc-key-1"
- // subject is the `sub` claim. The assumed role's trust policy conditions
- // on this value.
- subject = "e2e-oidc-subject"
+ // subjectEnv names the environment variable carrying the `sub` claim.
+ // The subject is produced by the control plane, travels through `formae
+ // connect` into the trust it provisions, and has to be the same string
+ // here or nothing the broker mints is accepted. The test owns both ends,
+ // so it supplies it rather than this file pinning a copy.
+ subjectEnv = "E2E_OIDC_SUBJECT"
// signingKeySecretID names the Secrets Manager secret whose SecretString
// is the PEM-encoded RSA private key matching keyID in the JWKS.
@@ -56,6 +60,7 @@ const (
type stub struct {
signingKey *rsa.PrivateKey
+ subject string
}
// Configure fetches the signing key before the broker starts serving. The
@@ -65,6 +70,14 @@ type stub struct {
func (s *stub) Configure(_ json.RawMessage) error {
ctx := context.Background()
+ // Read before the key: a broker with no subject can sign perfectly well
+ // and still mint a token nothing will accept, which surfaces as an opaque
+ // rejection at the far end rather than as the missing configuration it is.
+ s.subject = os.Getenv(subjectEnv)
+ if s.subject == "" {
+ return fmt.Errorf("%s is not set, so the broker has no subject to mint for", subjectEnv)
+ }
+
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(awsRegion))
if err != nil {
return fmt.Errorf("loading aws config: %w", err)
@@ -126,7 +139,7 @@ func (s *stub) IdentityToken(_ context.Context, req *credential.OidcIdentityToke
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": issuer,
- "sub": subject,
+ "sub": s.subject,
"aud": req.Audience,
"iat": jwt.NewNumericDate(now),
"exp": jwt.NewNumericDate(expiresAt),
diff --git a/tests/e2e/go/fixtures/oidc-echo-plugin/go.mod b/tests/e2e/go/fixtures/oidc-echo-plugin/go.mod
index 5995d2103..b5570813c 100644
--- a/tests/e2e/go/fixtures/oidc-echo-plugin/go.mod
+++ b/tests/e2e/go/fixtures/oidc-echo-plugin/go.mod
@@ -20,6 +20,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.43.7
github.com/aws/aws-sdk-go-v2/config v1.32.38
github.com/aws/aws-sdk-go-v2/service/sts v1.45.7
+ github.com/aws/smithy-go v1.27.8
github.com/platform-engineering-labs/formae/pkg/model v0.1.6
github.com/platform-engineering-labs/formae/pkg/plugin v0.0.0
)
@@ -38,7 +39,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/signin v1.5.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 // indirect
- github.com/aws/smithy-go v1.27.8 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
diff --git a/tests/e2e/go/fixtures/oidc-echo-plugin/plugin.go b/tests/e2e/go/fixtures/oidc-echo-plugin/plugin.go
index 3de6dd528..fa4faa061 100644
--- a/tests/e2e/go/fixtures/oidc-echo-plugin/plugin.go
+++ b/tests/e2e/go/fixtures/oidc-echo-plugin/plugin.go
@@ -5,40 +5,91 @@
package main
import (
+ "bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
+ "io"
+ "net/http"
+ "os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sts"
+ "github.com/aws/smithy-go"
pkgmodel "github.com/platform-engineering-labs/formae/pkg/model"
"github.com/platform-engineering-labs/formae/pkg/plugin"
"github.com/platform-engineering-labs/formae/pkg/plugin/resource"
)
+// Every coordinate this fixture needs comes from the environment the agent
+// was started with, because none of them exist until the run that provisions
+// them. `formae connect` makes the trust, and only then is there an audience
+// to mint for or a role to assume.
const (
- // audience the plugin asks its broker for, and the audience the role's
- // trust policy expects on the token it is handed.
- audience = "sts.amazonaws.com"
+ // audienceEnv names the audience the plugin asks its broker to mint for.
+ // A token carries exactly one, and each cloud spells it differently: AWS
+ // wants sts.amazonaws.com, GCP wants the workload identity provider's own
+ // resource name.
+ audienceEnv = "E2E_OIDC_AUDIENCE"
+
+ // assumeRoleARNEnv and gcpProjectEnv each select one cloud's exchange, and
+ // carry the one coordinate that exchange needs beyond the audience.
+ assumeRoleARNEnv = "E2E_OIDC_ASSUME_ROLE_ARN"
+ gcpProjectEnv = "E2E_OIDC_GCP_PROJECT"
// namespace this plugin serves, as its manifest declares it. Named in the
// recorded failure so a test can tell whose pairing was missing.
namespace = "OidcEcho"
- // assumeRoleARN is the standing role the minted token is exchanged for.
- // Its trust policy names the broker's issuer, subject, and audience.
- assumeRoleARN = "arn:aws:iam::942849037363:role/e2e-oidc-assume-role"
-
// assumeRoleSessionName labels the STS session. Stable, so the assumed
// role ARN a test reads back is stable too.
assumeRoleSessionName = "e2e-oidc-echo"
- // awsRegion is where the STS call is made.
+ // awsRegion is where the AWS STS call is made.
awsRegion = "us-west-2"
)
+// exchange is the cloud-specific half of the probe: what the token is spent
+// on once the broker has minted it.
+//
+// An agent runs exactly one, chosen by which coordinates the test put in its
+// environment, because a token is minted for one audience and is accepted by
+// one exchange only.
+type exchange interface {
+ // Spend exchanges the identity token for real credentials and reports
+ // proof of it: who the caller became, and how long the credentials last.
+ //
+ // The credentials themselves are deliberately not returned. They would
+ // land in the resource's properties, which is not a place credentials
+ // belong.
+ Spend(ctx context.Context, token string) (identity, expiration string, err error)
+}
+
+// resolveExchange picks the exchange the environment configured. Naming both
+// variables in the failure matters: the symptom of setting neither is a token
+// that was minted and then not spent, which looks like the exchange failing
+// rather than never having been asked for.
+func resolveExchange() (exchange, error) {
+ roleARN := os.Getenv(assumeRoleARNEnv)
+ project := os.Getenv(gcpProjectEnv)
+
+ switch {
+ case roleARN != "" && project != "":
+ return nil, fmt.Errorf("%s and %s are both set, so which cloud to exchange at is ambiguous",
+ assumeRoleARNEnv, gcpProjectEnv)
+ case roleARN != "":
+ return awsExchange{roleARN: roleARN}, nil
+ case project != "":
+ return gcpExchange{audience: os.Getenv(audienceEnv), project: project}, nil
+ default:
+ return nil, fmt.Errorf("neither %s nor %s is set, so there is nothing to exchange the token at",
+ assumeRoleARNEnv, gcpProjectEnv)
+ }
+}
+
// EchoPlugin serves OidcEcho::Tokens::Token. It holds no state beyond the
// token source the SDK installs, which resolves the paired broker per call.
type EchoPlugin struct {
@@ -64,16 +115,18 @@ func (p *EchoPlugin) LabelConfig() pkgmodel.LabelConfig {
return pkgmodel.LabelConfig{DefaultQuery: "$.probeLabel"}
}
-// tokenProperties asks the broker for a token, exchanges it at AWS STS, and
-// renders the resource's properties around both outcomes. A failure is
-// recorded in tokenError or stsError rather than failed outright, so a test
-// asserting the unpaired case reads an exact message instead of whatever an
-// operator error renders to. Real plugins fail closed here; this one is only
-// proving the wiring.
+// tokenProperties asks the broker for a token, spends it at the configured
+// exchange, and renders the resource's properties around both outcomes. A
+// failure is recorded in tokenError or exchangeError rather than failed
+// outright, so a test asserting the unpaired case reads an exact message
+// instead of whatever an operator error renders to. Real plugins fail closed
+// here; this one is only proving the wiring.
func (p *EchoPlugin) tokenProperties(ctx context.Context, probeLabel string) (json.RawMessage, error) {
var token, tokenError string
- switch {
+ switch audience := os.Getenv(audienceEnv); {
+ case audience == "":
+ tokenError = fmt.Sprintf("%s is not set, so there is no audience to mint for", audienceEnv)
case p.tokens == nil:
// The SDK installs the source on every OidcAware plugin, so this only
// fires if that wiring broke.
@@ -87,51 +140,118 @@ func (p *EchoPlugin) tokenProperties(ctx context.Context, probeLabel string) (js
}
}
- // With no token there is nothing to exchange, so the STS outputs stay
+ // With no token there is nothing to spend, so the exchange outputs stay
// empty rather than carrying the error of a call that was never worth
// making.
- var assumedRoleARN, expiration, stsError string
+ var identity, expiration, exchangeError string
if token != "" {
- assumedRoleARN, expiration, stsError = assumeRoleWithToken(ctx, token)
+ identity, expiration, exchangeError = spend(ctx, token)
}
return json.Marshal(map[string]string{
- "probeLabel": probeLabel,
- "token": token,
- "tokenError": tokenError,
- "stsAssumedRoleArn": assumedRoleARN,
- "stsExpiration": expiration,
- "stsError": stsError,
+ "probeLabel": probeLabel,
+ "token": token,
+ "tokenError": tokenError,
+ "exchangeIdentity": identity,
+ "exchangeExpiration": expiration,
+ "exchangeError": exchangeError,
})
}
-// assumeRoleWithToken exchanges an identity token for role credentials at
-// AWS STS and reports proof of the exchange: who the caller became and how
-// long the credentials last. The access key and secret are deliberately not
-// returned: they would land in the resource's properties, which is not a
-// place credentials belong.
-func assumeRoleWithToken(ctx context.Context, token string) (assumedRoleARN, expiration, stsError string) {
- cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(awsRegion))
+// spend runs the configured exchange and flattens its outcome into the three
+// strings the properties document carries.
+func spend(ctx context.Context, token string) (identity, expiration, exchangeError string) {
+ ex, err := resolveExchange()
if err != nil {
- return "", "", fmt.Sprintf("loading aws config: %s", err)
+ return "", "", err.Error()
}
-
- out, err := sts.NewFromConfig(cfg).AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityInput{
- RoleArn: aws.String(assumeRoleARN),
- RoleSessionName: aws.String(assumeRoleSessionName),
- WebIdentityToken: aws.String(token),
- })
+ identity, expiration, err = ex.Spend(ctx, token)
if err != nil {
- return "", "", fmt.Sprintf("assume role with web identity: %s", err)
+ return "", "", err.Error()
}
+ return identity, expiration, ""
+}
- if out.AssumedRoleUser != nil && out.AssumedRoleUser.Arn != nil {
- assumedRoleARN = *out.AssumedRoleUser.Arn
+// awsExchange trades the identity token for role credentials at AWS STS. The
+// role is the one `formae connect` provisioned, and its trust policy pins the
+// broker's issuer, subject and audience, so a token STS accepts here is proof
+// the whole chain agrees.
+type awsExchange struct {
+ roleARN string
+}
+
+// assumeRolePropagation bounds how long the exchange keeps trying a role that
+// is not assumable yet, and how often.
+//
+// A role created moments ago is not immediately assumable: IAM propagates
+// asynchronously and STS answers AccessDenied in the meantime. That is the
+// ordinary state of a role `formae connect` made seconds earlier, so reporting
+// the first refusal would fail the run over a trust policy that is correct.
+//
+// The window is a child deadline, not a wall-clock check between attempts. A
+// check between attempts bounds when the last request may *start*, not when it
+// may finish, so a slow attempt begun just inside the window can run past the
+// agent's 60s plugin call deadline and turn a recorded exchangeError into an
+// operation timeout — which reads as the harness breaking rather than as the
+// exchange refusing. The margin below leaves the operator's deadline room to
+// be the one that never fires.
+const (
+ assumeRolePropagationWindow = 30 * time.Second
+ assumeRolePropagationInterval = 2 * time.Second
+)
+
+func (e awsExchange) Spend(ctx context.Context, token string) (identity, expiration string, err error) {
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(awsRegion))
+ if err != nil {
+ return "", "", fmt.Errorf("loading aws config: %w", err)
}
- if out.Credentials != nil && out.Credentials.Expiration != nil {
- expiration = out.Credentials.Expiration.Format(time.RFC3339)
+ client := sts.NewFromConfig(cfg)
+
+ // Bounding the requests themselves, so nothing this loop starts can outlive
+ // the window.
+ ctx, cancel := context.WithTimeout(ctx, assumeRolePropagationWindow)
+ defer cancel()
+
+ for {
+ out, err := client.AssumeRoleWithWebIdentity(ctx, &sts.AssumeRoleWithWebIdentityInput{
+ RoleArn: aws.String(e.roleARN),
+ RoleSessionName: aws.String(assumeRoleSessionName),
+ WebIdentityToken: aws.String(token),
+ })
+ if err == nil {
+ if out.AssumedRoleUser != nil && out.AssumedRoleUser.Arn != nil {
+ identity = *out.AssumedRoleUser.Arn
+ }
+ if out.Credentials != nil && out.Credentials.Expiration != nil {
+ expiration = out.Credentials.Expiration.Format(time.RFC3339)
+ }
+ return identity, expiration, nil
+ }
+ // Only the refusal propagation produces is worth waiting out. A
+ // malformed ARN, a rejected token or an expired credential is settled
+ // on the first answer, and spending the whole window on it buries the
+ // reason under a delay that looks like a hang.
+ if !awaitingPropagation(err) {
+ return "", "", fmt.Errorf("assume role with web identity: %w", err)
+ }
+ select {
+ case <-ctx.Done():
+ return "", "", fmt.Errorf("assume role with web identity: %w", err)
+ case <-time.After(assumeRolePropagationInterval):
+ }
}
- return assumedRoleARN, expiration, ""
+}
+
+// awaitingPropagation reports whether err is the refusal STS gives for a role
+// whose trust policy has not propagated yet.
+//
+// AccessDenied is also what a genuinely wrong trust policy produces, and the
+// two are not distinguishable from the outside — which is the whole reason the
+// window exists rather than a poll for readiness. Everything else is a settled
+// answer and is returned as it stands.
+func awaitingPropagation(err error) bool {
+ var api smithy.APIError
+ return errors.As(err, &api) && api.ErrorCode() == "AccessDenied"
}
// probeLabelOf reads the probeLabel property out of a properties document.
@@ -243,3 +363,149 @@ func failure(operation resource.Operation, err error) *resource.ProgressResult {
StatusMessage: err.Error(),
}
}
+
+// gcpExchange trades the identity token for a federated access token at
+// Google's STS, then spends that token reading the project.
+//
+// The two steps are both needed and prove different things. The exchange
+// proves the workload identity provider `formae connect` created trusts the
+// broker's issuer and accepts its subject and audience; the project read
+// proves connect also granted that federated principal something, which is
+// the half a successful exchange alone would not show.
+//
+// It is written against the REST endpoints rather than the Google SDK because
+// the SDK's federation support wants a credential-configuration file naming a
+// token source on disk, and the token here arrives in memory from the broker.
+type gcpExchange struct {
+ // audience is the workload identity provider's full resource name, which
+ // is both what the token was minted for and what the exchange is
+ // addressed to. Google pins the provider's allowed audiences to this same
+ // string, so the two cannot drift.
+ audience string
+ project string
+}
+
+// gcpTokenExchangeURL and gcpProjectURL are Google's STS token endpoint and
+// the project read used as proof the exchanged credentials work.
+const (
+ gcpTokenExchangeURL = "https://sts.googleapis.com/v1/token"
+ gcpProjectURL = "https://cloudresourcemanager.googleapis.com/v1/projects/"
+)
+
+func (e gcpExchange) Spend(ctx context.Context, token string) (identity, expiration string, err error) {
+ if e.audience == "" {
+ return "", "", fmt.Errorf("%s is not set, so there is no workload identity provider to exchange at", audienceEnv)
+ }
+
+ accessToken, lifetime, err := e.federate(ctx, token)
+ if err != nil {
+ return "", "", err
+ }
+
+ number, err := e.readProjectNumber(ctx, accessToken)
+ if err != nil {
+ return "", "", err
+ }
+
+ // The identity is reported as the project the credentials could actually
+ // read, for the same reason the AWS side reports the assumed role ARN:
+ // a token that exchanged but reaches nothing has not proven access.
+ return "projects/" + number, time.Now().Add(lifetime).UTC().Format(time.RFC3339), nil
+}
+
+// federate performs the RFC 8693 token exchange and returns the access token
+// and how long it lasts.
+func (e gcpExchange) federate(ctx context.Context, token string) (string, time.Duration, error) {
+ body, err := json.Marshal(map[string]string{
+ "audience": e.audience,
+ "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
+ "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
+ "scope": "https://www.googleapis.com/auth/cloud-platform",
+ "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
+ "subjectToken": token,
+ })
+ if err != nil {
+ return "", 0, fmt.Errorf("building the token exchange request: %w", err)
+ }
+
+ data, err := gcpPost(ctx, gcpTokenExchangeURL, "", body)
+ if err != nil {
+ return "", 0, fmt.Errorf("exchanging the identity token: %w", err)
+ }
+
+ var exchanged struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ }
+ if err := json.Unmarshal(data, &exchanged); err != nil {
+ return "", 0, fmt.Errorf("parsing the token exchange response: %w", err)
+ }
+ if exchanged.AccessToken == "" {
+ return "", 0, fmt.Errorf("the token exchange returned no access token")
+ }
+ return exchanged.AccessToken, time.Duration(exchanged.ExpiresIn) * time.Second, nil
+}
+
+// readProjectNumber spends the federated access token on the one read that
+// shows it carries access, and returns what it read back.
+func (e gcpExchange) readProjectNumber(ctx context.Context, accessToken string) (string, error) {
+ data, err := gcpGet(ctx, gcpProjectURL+e.project, accessToken)
+ if err != nil {
+ return "", fmt.Errorf("reading project %s with the exchanged credentials: %w", e.project, err)
+ }
+
+ var project struct {
+ ProjectNumber string `json:"projectNumber"`
+ }
+ if err := json.Unmarshal(data, &project); err != nil {
+ return "", fmt.Errorf("parsing the project read response: %w", err)
+ }
+ if project.ProjectNumber == "" {
+ return "", fmt.Errorf("the project read returned no project number")
+ }
+ return project.ProjectNumber, nil
+}
+
+func gcpPost(ctx context.Context, url, accessToken string, body []byte) ([]byte, error) {
+ return gcpDo(ctx, http.MethodPost, url, accessToken, body)
+}
+
+func gcpGet(ctx context.Context, url, accessToken string) ([]byte, error) {
+ return gcpDo(ctx, http.MethodGet, url, accessToken, nil)
+}
+
+// gcpDo makes one call and returns the body. A non-2xx carries Google's own
+// error text, capped: the whole value of this fixture on a failing run is the
+// reason Google gave, and a refusal at the exchange and a refusal at the read
+// are different problems that read almost identically without it.
+func gcpDo(ctx context.Context, method, url, accessToken string, body []byte) ([]byte, error) {
+ var reader io.Reader
+ if body != nil {
+ reader = bytes.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, url, reader)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if accessToken != "" {
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
+ }
+ return data, nil
+}
diff --git a/tests/e2e/go/gcpiam.go b/tests/e2e/go/gcpiam.go
new file mode 100644
index 000000000..42b43abcf
--- /dev/null
+++ b/tests/e2e/go/gcpiam.go
@@ -0,0 +1,203 @@
+// © 2026 Platform Engineering Labs Inc.
+//
+// SPDX-License-Identifier: FSL-1.1-ALv2
+
+//go:build e2e
+
+package e2e_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/oauth2/google"
+)
+
+// Revoking what `formae connect gcp` granted.
+//
+// The provisioner deliberately leaves the pool and the provider standing —
+// both are shared, and deleting them would revoke every other installation
+// connected to the same project — and removes only the IAM bindings for its
+// own principal. This does the same, because the alternative is worse than
+// leaving one set behind: the subject is per-run, so without this every run
+// would deposit another principal holding roles/editor and
+// roles/resourcemanager.projectIamAdmin, permanently, on a project whose
+// issuer's signing key is a standing fixture.
+
+const (
+ // gcpIamPolicyURL is the project-level IAM policy endpoint. v1 is what
+ // provx uses for the same bindings.
+ gcpIamPolicyURL = "https://cloudresourcemanager.googleapis.com/v1/projects/"
+
+ // gcpPolicyVersion asks for the representation that can express
+ // conditional bindings. The bindings here are unconditional, but a policy
+ // read at a lower version silently drops any conditional binding it cannot
+ // represent, and writing that back would delete somebody else's grant.
+ gcpPolicyVersion = 3
+)
+
+// gcpPolicy is the project IAM policy, read and written whole. Unknown fields
+// are preserved only insofar as they are not needed: the etag makes the
+// read-modify-write safe, so a concurrent editor loses the race rather than
+// having their change silently dropped.
+type gcpPolicy struct {
+ Version int `json:"version"`
+ Etag string `json:"etag"`
+ Bindings []gcpBinding `json:"bindings"`
+}
+
+type gcpBinding struct {
+ Role string `json:"role"`
+ Members []string `json:"members"`
+ Condition json.RawMessage `json:"condition,omitempty"`
+}
+
+// RevokeGCPPrincipal removes every binding member equal to principal from the
+// project's IAM policy, and fails the run if it removed nothing.
+//
+// The empty case is a failure rather than a no-op because of when this is
+// called: only once connect has reported the provider it provisioned, by which
+// point the binding it granted must exist. Treating "found nothing" as success
+// would make a principal string that does not match what Google stores
+// indistinguishable from a clean revocation — leaving privileged bindings
+// standing under a green run, which is the exact failure this cleanup exists
+// to prevent.
+//
+// A failure does fail the run, deliberately: bindings this run granted and
+// could not take back are worth being told about. It records rather than
+// aborts, so one failed step does not skip the rest.
+func RevokeGCPPrincipal(t *testing.T, project, principal string) {
+ t.Helper()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+
+ client, err := google.DefaultClient(ctx, "https://www.googleapis.com/auth/cloud-platform")
+ if err != nil {
+ t.Errorf("cleanup: no Google credentials to revoke %s with: %v", principal, err)
+ return
+ }
+
+ // One retry: the read-modify-write races anything else editing the policy,
+ // and the etag turns that race into a 409 rather than a lost update.
+ for attempt := range 2 {
+ policy, err := getGCPPolicy(ctx, client, project)
+ if err != nil {
+ t.Errorf("cleanup: reading the IAM policy of %s: %v", project, err)
+ return
+ }
+
+ filtered, removed := withoutMember(policy.Bindings, principal)
+ if !removed {
+ t.Errorf("cleanup: %s held no bindings on %s to revoke; connect granted them, so either "+
+ "something else removed them or this principal does not name what Google stored",
+ principal, project)
+ return
+ }
+ policy.Bindings = filtered
+ policy.Version = gcpPolicyVersion
+
+ err = setGCPPolicy(ctx, client, project, policy)
+ if err == nil {
+ return
+ }
+ if attempt == 1 || !strings.Contains(err.Error(), "HTTP 409") {
+ t.Errorf("cleanup: revoking %s on %s: %v", principal, project, err)
+ return
+ }
+ }
+}
+
+// withoutMember drops member from every binding, and drops any binding it
+// empties: a binding with no members is not valid to write back.
+func withoutMember(bindings []gcpBinding, member string) ([]gcpBinding, bool) {
+ var kept []gcpBinding
+ removed := false
+ for _, b := range bindings {
+ var members []string
+ for _, m := range b.Members {
+ if m == member {
+ removed = true
+ continue
+ }
+ members = append(members, m)
+ }
+ if len(members) == 0 {
+ continue
+ }
+ b.Members = members
+ kept = append(kept, b)
+ }
+ return kept, removed
+}
+
+func getGCPPolicy(ctx context.Context, client *http.Client, project string) (*gcpPolicy, error) {
+ body, _ := json.Marshal(map[string]any{
+ "options": map[string]any{"requestedPolicyVersion": gcpPolicyVersion},
+ })
+ data, err := gcpIamCall(ctx, client, gcpIamPolicyURL+project+":getIamPolicy", body)
+ if err != nil {
+ return nil, err
+ }
+ var policy gcpPolicy
+ if err := json.Unmarshal(data, &policy); err != nil {
+ return nil, fmt.Errorf("parsing the IAM policy: %w", err)
+ }
+ return &policy, nil
+}
+
+func setGCPPolicy(ctx context.Context, client *http.Client, project string, policy *gcpPolicy) error {
+ body, err := json.Marshal(map[string]any{"policy": policy})
+ if err != nil {
+ return err
+ }
+ _, err = gcpIamCall(ctx, client, gcpIamPolicyURL+project+":setIamPolicy", body)
+ return err
+}
+
+func gcpIamCall(ctx context.Context, client *http.Client, url string, body []byte) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
+ }
+ return data, nil
+}
+
+// GCPPrincipalFor builds the IAM member string for a federated subject in the
+// pool the given provider belongs to.
+//
+// It is derived from the provider resource name connect reported rather than
+// assembled from parts the test guesses at, so a cleanup can only ever revoke
+// the principal that this run's own registration named.
+func GCPPrincipalFor(t *testing.T, providerName, subject string) string {
+ t.Helper()
+
+ // //iam.googleapis.com/projects/N/locations/global/workloadIdentityPools/P/providers/Q
+ pool, _, found := strings.Cut(providerName, "/providers/")
+ if !found {
+ t.Fatalf("workload identity provider %q does not name a pool", providerName)
+ }
+ return "principal:" + pool + "/subject/" + subject
+}
diff --git a/tests/e2e/go/go.mod b/tests/e2e/go/go.mod
index 2a1e8503c..e6bbf2fc2 100644
--- a/tests/e2e/go/go.mod
+++ b/tests/e2e/go/go.mod
@@ -1,6 +1,6 @@
module github.com/platform-engineering-labs/formae/tests/e2e
-go 1.25
+go 1.25.0
require (
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
@@ -10,9 +10,11 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/service/iam v1.53.3
golang.org/x/crypto v0.41.0
+ golang.org/x/oauth2 v0.36.0
)
require (
+ cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
diff --git a/tests/e2e/go/go.sum b/tests/e2e/go/go.sum
index 479f2530d..b3088bb92 100644
--- a/tests/e2e/go/go.sum
+++ b/tests/e2e/go/go.sum
@@ -1,3 +1,5 @@
+cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
+cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
@@ -77,6 +79,8 @@ golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sU
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
diff --git a/tests/e2e/go/oidc_credential_gcp_test.go b/tests/e2e/go/oidc_credential_gcp_test.go
new file mode 100644
index 000000000..cab7c9a45
--- /dev/null
+++ b/tests/e2e/go/oidc_credential_gcp_test.go
@@ -0,0 +1,175 @@
+// © 2026 Platform Engineering Labs Inc.
+//
+// SPDX-License-Identifier: FSL-1.1-ALv2
+
+//go:build e2e
+
+package e2e_test
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+// gcpProjectEnv names the project the GCP half of the oidc-credential chain
+// federates into. It is its own project rather than the one the GCP plugin's
+// suites use, because provx fixes the workload identity pool and provider ids
+// per project and refuses to converge a provider that trusts a different
+// issuer: a project already carrying the production-issuer connection cannot
+// also carry this one.
+const gcpProjectEnv = "E2E_GCP_PROJECT"
+
+// gcpE2EProject returns the project, or skips.
+//
+// A skip rather than a failure because the credentials are conditional in the
+// workflow, the way Azure's are: a fork without them should report that this
+// did not run, not that it broke. The message names the variable, so a skip in
+// a run that was supposed to have credentials is legible as the misconfigured
+// job it is.
+func gcpE2EProject(t *testing.T) string {
+ t.Helper()
+
+ project := os.Getenv(gcpProjectEnv)
+ if project == "" {
+ t.Skipf("%s is not set, so there is no GCP project to federate into", gcpProjectEnv)
+ }
+ return project
+}
+
+// connectProvisionsGcpTrust drives `formae connect gcp` against a stub control
+// plane and returns the workload identity provider it provisioned.
+//
+// The pool and the provider are left standing: both are fixed per project and
+// shared between installations, and provx's own Delete leaves them for that
+// reason. The IAM bindings are not left standing. The subject is this run's,
+// so they would accumulate one privileged principal per run, each holding
+// roles/editor and roles/resourcemanager.projectIamAdmin on a project whose
+// issuer signs with a standing key — a growing grant with no expiry and no
+// owner.
+func connectProvisionsGcpTrust(t *testing.T, bin, project string) string {
+ t.Helper()
+
+ stub := StartConnectStub(t, connectSetup{
+ CloudSubject: oidcEchoSubject(),
+ // GCP carries no role, but the setup read requires all three
+ // coordinates: the control plane serves one document for every cloud.
+ CloudRoleName: oidcConnectRoleName(),
+ Issuer: oidcEchoIssuer,
+ })
+ configPath := WriteHostedConnectConfig(t, t.TempDir(),
+ stagedOidcPluginDir(t, oidcAuthPluginDirEnv), stub.URL)
+
+ doc := RunConnect(t, bin, ConnectEnv(stub.URL, oidcEchoIssuer),
+ "gcp",
+ "--config", configPath,
+ "--project", project,
+ "--no-input",
+ )
+
+ if doc.Phase != "registered" {
+ t.Fatalf("connect phase: got %q, want %q", doc.Phase, "registered")
+ }
+ if doc.Cloud != "gcp" {
+ t.Errorf("connect cloud: got %q, want %q", doc.Cloud, "gcp")
+ }
+ if doc.Account != project {
+ t.Errorf("connect account: got %q, want %q", doc.Account, project)
+ }
+ if doc.RoleArn != "" {
+ t.Errorf("connect reported a roleArn %q on a GCP registration", doc.RoleArn)
+ }
+ if !strings.HasPrefix(doc.WorkloadIdentityProvider, "//iam.googleapis.com/projects/") {
+ t.Fatalf("connect workloadIdentityProvider %q is not a provider resource name", doc.WorkloadIdentityProvider)
+ }
+
+ // Registered only once the provider name is known, since the principal is
+ // derived from it. A run that fails between here and the end still revokes
+ // what it granted.
+ t.Cleanup(func() {
+ RevokeGCPPrincipal(t, project, GCPPrincipalFor(t, doc.WorkloadIdentityProvider, oidcEchoSubject()))
+ })
+
+ registrations := stub.Registrations()
+ if len(registrations) != 1 {
+ t.Fatalf("stub control plane received %d registrations, want 1: %+v", len(registrations), registrations)
+ }
+ got := registrations[0]
+ coordinate, present := got.Coordinate()
+ if got.Cloud != "gcp" || got.Account != project || !present || coordinate != doc.WorkloadIdentityProvider {
+ t.Errorf("registration: got cloud %q account %q workloadIdentityProvider %q (present %v), want cloud gcp, account %s, provider %s",
+ got.Cloud, got.Account, coordinate, present, project, doc.WorkloadIdentityProvider)
+ }
+ // Absent, not merely empty: the control plane's schema is a discriminated
+ // union that rejects a field belonging to another variant, so a roleArn
+ // sent as "" would be refused just as one carrying a value would. The
+ // decoder keeps the two apart rather than collapsing both to "".
+ if got.ForeignCoordinatePresent() {
+ t.Errorf("registration carried a roleArn field on a GCP connection")
+ }
+
+ return doc.WorkloadIdentityProvider
+}
+
+// TestOidcCredential_GcpTokenExchangesForRealCredentials is the GCP sibling of
+// the AWS chain, and proves the same property against a different federation
+// mechanism: `formae connect` provisions the workload identity pool, the
+// provider trusting the broker's issuer, and the project bindings; then the
+// broker mints a token for the provider's own resource name, Google's STS
+// exchanges it for a federated access token, and that token reads the project.
+//
+// The project read is not decoration. A token exchange proves the provider
+// trusts the issuer, subject and audience; only spending the result proves
+// connect also granted the federated principal something, which is the half
+// the exchange alone would leave unchecked.
+func TestOidcCredential_GcpTokenExchangesForRealCredentials(t *testing.T) {
+ bin := FormaeBinary(t)
+ project := gcpE2EProject(t)
+
+ provider := connectProvisionsGcpTrust(t, bin, project)
+
+ agent := StartAgent(t, bin,
+ WithPluginDir(stagedOidcPluginDir(t, oidcPluginDirEnv)),
+ WithEnv(
+ "E2E_OIDC_SUBJECT="+oidcEchoSubject(),
+ // On GCP the audience is the provider's own resource name, and
+ // Google pins the provider's allowed audiences to that same
+ // string, so the token and the exchange cannot disagree.
+ "E2E_OIDC_AUDIENCE="+provider,
+ "E2E_OIDC_GCP_PROJECT="+project,
+ ),
+ )
+ agent.WaitForOidcBroker(t, oidcEchoNamespace, 60*time.Second)
+
+ echo := applyOidcEchoFixture(t, bin, agent)
+
+ if got := echoOutput(t, echo, "tokenError"); got != "" {
+ t.Fatalf("tokenError: got %q, want empty", got)
+ }
+
+ header, claims := decodeJWT(t, echoOutput(t, echo, "token"))
+ for _, check := range []struct {
+ doc map[string]any
+ key string
+ expected string
+ }{
+ {header, "alg", "RS256"},
+ {header, "kid", oidcEchoKeyID},
+ {claims, "iss", oidcEchoIssuer},
+ {claims, "sub", oidcEchoSubject()},
+ {claims, "aud", provider},
+ } {
+ if got := jwtString(t, check.doc, check.key); got != check.expected {
+ t.Errorf("token %s: got %q, want %q", check.key, got, check.expected)
+ }
+ }
+
+ if got := echoOutput(t, echo, "exchangeError"); got != "" {
+ t.Fatalf("exchangeError: got %q, want empty", got)
+ }
+ if got := echoOutput(t, echo, "exchangeIdentity"); !strings.HasPrefix(got, "projects/") {
+ t.Errorf("exchangeIdentity %q does not name the project the credentials read", got)
+ }
+ requireCredentialsOutlive(t, echoOutput(t, echo, "exchangeExpiration"))
+}
diff --git a/tests/e2e/go/oidc_credential_test.go b/tests/e2e/go/oidc_credential_test.go
index 55e20ac12..c99ce32bf 100644
--- a/tests/e2e/go/oidc_credential_test.go
+++ b/tests/e2e/go/oidc_credential_test.go
@@ -18,15 +18,27 @@ import (
const (
// The audience the echo plugin asks for in Create, and the claims the
- // stub broker signs into the token it mints for it. These name standing
- // AWS resources: the static OIDC issuer registered as an IAM identity
- // provider, the key in its published JWKS, and the subject and role its
- // trust policy conditions on.
+ // stub broker signs into the token it mints for it. The issuer and key
+ // are standing AWS resources: a static OIDC issuer served from S3 and the
+ // key in its published JWKS. The subject is what the stub control plane
+ // hands connect, so it is also what the provisioned role's trust policy
+ // conditions on.
oidcEchoAudience = "sts.amazonaws.com"
oidcEchoIssuer = "https://e2e-oidc-issuer-942849037363.s3.us-west-2.amazonaws.com"
oidcEchoKeyID = "e2e-oidc-key-1"
- oidcEchoSubject = "e2e-oidc-subject"
- oidcEchoRoleName = "e2e-oidc-assume-role"
+
+ // The tenant half of the subject. The installation half is per-run, which
+ // is what makes the trust connect provisions per-run too.
+ oidcEchoTenantID = "2eOidcConnectE2eTenant00001"
+
+ // The e2e account, pinned rather than derived: connect is told which
+ // account to connect and never infers one from ambient credentials, and
+ // the test asserts on the role ARN that produces.
+ oidcEchoAccount = "942849037363"
+
+ // The AWS shared-config profile connect provisions with, written by the
+ // e2e workflow's credentials step.
+ oidcConnectAWSProfile = "e2e-test"
oidcEchoStackQuery = "stack:e2e-oidc-echo"
oidcEchoLabel = "e2e-oidc-token"
@@ -41,6 +53,26 @@ const (
oidcPluginDirNoBrokerEnv = "E2E_OIDC_PLUGIN_DIR_NO_BROKER"
)
+// oidcEchoSubject is the subject the control plane produces, in the grammar it
+// really uses: the `fai:` namespace, a tenant, and the installation being
+// connected. Connect takes it verbatim into the trust it provisions and the
+// GCP path parses it back apart, so a subject of a made-up shape would
+// exercise a string production never emits.
+//
+// The installation half is this run's, so the trust provisioned against it is
+// this run's: an exchange that succeeds could not have been riding on what an
+// earlier run established.
+func oidcEchoSubject() string { return "fai:" + oidcEchoTenantID + "/" + ConnectInstallationID() }
+
+// oidcConnectRoleName is the role connect provisions and the echo plugin then
+// assumes.
+//
+// Per-run for the same reason as the subject, and because cleanup deletes this
+// role outright: a shared name would let one run's teardown remove trust
+// another run was still using. The suite's own prefix keeps it inside the
+// pre-cleanup purge, so a run that dies before its teardown is reclaimed.
+func oidcConnectRoleName() string { return "formae-e2e-oidc-connect-" + ConnectRunSuffix() }
+
// stagedOidcPluginDir returns the staged plugin tree named by the given
// environment variable. `make test-e2e` builds both fixture binaries and
// stages them; running the suite by hand without it leaves nothing to
@@ -122,6 +154,22 @@ func decodeJWT(t *testing.T, token string) (header, claims map[string]any) {
return decodeJWTSegment(t, "header", segments[0]), decodeJWTSegment(t, "claims", segments[1])
}
+// requireCredentialsOutlive holds the exchange's reported expiry to being a
+// real one: an RFC3339 instant still in the future. It is the difference
+// between an exchange that returned credentials and one that returned a
+// document shaped like credentials.
+func requireCredentialsOutlive(t *testing.T, expiration string) {
+ t.Helper()
+
+ expiresAt, err := time.Parse(time.RFC3339, expiration)
+ if err != nil {
+ t.Fatalf("exchangeExpiration %q is not RFC3339: %v", expiration, err)
+ }
+ if !expiresAt.After(time.Now()) {
+ t.Errorf("exchangeExpiration %q is not in the future", expiration)
+ }
+}
+
// jwtString reads a string-valued entry out of a decoded token document.
func jwtString(t *testing.T, doc map[string]any, key string) string {
t.Helper()
@@ -137,14 +185,92 @@ func jwtString(t *testing.T, doc map[string]any, key string) string {
return str
}
-// TestOidcCredential_TokenExchangesForRealCredentials proves the whole chain:
-// the agent discovers and spawns the stub broker, pairs it with the echo
-// plugin's namespace, the signed token the broker mints for the audience the
-// plugin asks for arrives inside the plugin's Create, and AWS STS accepts
-// that token and exchanges it for credentials on the standing role.
+// connectProvisionsTrust drives `formae connect aws` against a stub control
+// plane and returns the ARN of the role it provisioned.
+//
+// This is the half of the chain that establishes trust. Connect reads the
+// subject, role name and issuer from the control plane and provisions against
+// them verbatim: the OIDC provider for the standing issuer (which already
+// exists, so it is validated and reused) and a role whose trust policy pins
+// that provider, that subject, and the STS audience. Nothing here is
+// hand-provisioned, and nothing is asserted from the CLI's prose — the
+// machine document and the registration the stub received are the evidence.
+func connectProvisionsTrust(t *testing.T, bin string) string {
+ t.Helper()
+
+ stub := StartConnectStub(t, connectSetup{
+ CloudSubject: oidcEchoSubject(),
+ CloudRoleName: oidcConnectRoleName(),
+ Issuer: oidcEchoIssuer,
+ })
+ configPath := WriteHostedConnectConfig(t, t.TempDir(),
+ stagedOidcPluginDir(t, oidcAuthPluginDirEnv), stub.URL)
+
+ // Registered before the run, not after it: a run that provisions the role
+ // and then fails to register still leaves the role standing.
+ t.Cleanup(func() { DeleteIAMRole(t, oidcConnectRoleName()) })
+
+ doc := RunConnect(t, bin, ConnectEnv(stub.URL, oidcEchoIssuer),
+ "aws",
+ "--config", configPath,
+ "--account", oidcEchoAccount,
+ "--profile-aws", oidcConnectAWSProfile,
+ "--no-input",
+ )
+
+ if doc.Phase != "registered" {
+ t.Fatalf("connect phase: got %q, want %q", doc.Phase, "registered")
+ }
+ if doc.Cloud != "aws" {
+ t.Errorf("connect cloud: got %q, want %q", doc.Cloud, "aws")
+ }
+ if doc.Account != oidcEchoAccount {
+ t.Errorf("connect account: got %q, want %q", doc.Account, oidcEchoAccount)
+ }
+ if !strings.HasSuffix(doc.RoleArn, ":role/"+oidcConnectRoleName()) {
+ t.Fatalf("connect roleArn %q does not name role %q", doc.RoleArn, oidcConnectRoleName())
+ }
+
+ // What the control plane was told, rather than what the CLI printed: the
+ // registration is the connection, and a run that provisioned without
+ // declaring it has not connected the account.
+ registrations := stub.Registrations()
+ if len(registrations) != 1 {
+ t.Fatalf("stub control plane received %d registrations, want 1: %+v", len(registrations), registrations)
+ }
+ got := registrations[0]
+ coordinate, present := got.Coordinate()
+ if got.Cloud != "aws" || got.Account != oidcEchoAccount || !present || coordinate != doc.RoleArn {
+ t.Errorf("registration: got cloud %q account %q roleArn %q (present %v), want cloud aws, account %s, roleArn %s",
+ got.Cloud, got.Account, coordinate, present, oidcEchoAccount, doc.RoleArn)
+ }
+ if got.ForeignCoordinatePresent() {
+ t.Errorf("registration carried a GCP coordinate on an AWS connection")
+ }
+
+ return doc.RoleArn
+}
+
+// TestOidcCredential_TokenExchangesForRealCredentials proves the whole chain,
+// from establishing trust to spending it: `formae connect` provisions the
+// identity provider and the role and registers them; then the agent discovers
+// and spawns the stub broker, pairs it with the echo plugin's namespace, the
+// signed token the broker mints for the audience the plugin asks for arrives
+// inside the plugin's Create, and AWS STS accepts that token against the role
+// connect just made.
func TestOidcCredential_TokenExchangesForRealCredentials(t *testing.T) {
bin := FormaeBinary(t)
- agent := StartAgent(t, bin, WithPluginDir(stagedOidcPluginDir(t, oidcPluginDirEnv)))
+
+ roleArn := connectProvisionsTrust(t, bin)
+
+ agent := StartAgent(t, bin,
+ WithPluginDir(stagedOidcPluginDir(t, oidcPluginDirEnv)),
+ WithEnv(
+ "E2E_OIDC_SUBJECT="+oidcEchoSubject(),
+ "E2E_OIDC_AUDIENCE="+oidcEchoAudience,
+ "E2E_OIDC_ASSUME_ROLE_ARN="+roleArn,
+ ),
+ )
agent.WaitForOidcBroker(t, oidcEchoNamespace, 60*time.Second)
echo := applyOidcEchoFixture(t, bin, agent)
@@ -162,7 +288,7 @@ func TestOidcCredential_TokenExchangesForRealCredentials(t *testing.T) {
{header, "alg", "RS256"},
{header, "kid", oidcEchoKeyID},
{claims, "iss", oidcEchoIssuer},
- {claims, "sub", oidcEchoSubject},
+ {claims, "sub", oidcEchoSubject()},
{claims, "aud", oidcEchoAudience},
} {
if got := jwtString(t, check.doc, check.key); got != check.expected {
@@ -170,21 +296,13 @@ func TestOidcCredential_TokenExchangesForRealCredentials(t *testing.T) {
}
}
- if got := echoOutput(t, echo, "stsError"); got != "" {
- t.Fatalf("stsError: got %q, want empty", got)
- }
- if got := echoOutput(t, echo, "stsAssumedRoleArn"); !strings.Contains(got, oidcEchoRoleName) {
- t.Errorf("stsAssumedRoleArn %q does not name role %q", got, oidcEchoRoleName)
- }
-
- expiration := echoOutput(t, echo, "stsExpiration")
- expiresAt, err := time.Parse(time.RFC3339, expiration)
- if err != nil {
- t.Fatalf("stsExpiration %q is not RFC3339: %v", expiration, err)
+ if got := echoOutput(t, echo, "exchangeError"); got != "" {
+ t.Fatalf("exchangeError: got %q, want empty", got)
}
- if !expiresAt.After(time.Now()) {
- t.Errorf("stsExpiration %q is not in the future", expiration)
+ if got := echoOutput(t, echo, "exchangeIdentity"); !strings.Contains(got, oidcConnectRoleName()) {
+ t.Errorf("exchangeIdentity %q does not name role %q", got, oidcConnectRoleName())
}
+ requireCredentialsOutlive(t, echoOutput(t, echo, "exchangeExpiration"))
}
// TestOidcCredential_NoBrokerFailsClosed proves a plugin whose namespace has
@@ -193,7 +311,12 @@ func TestOidcCredential_TokenExchangesForRealCredentials(t *testing.T) {
// both the missing pairing and its own namespace.
func TestOidcCredential_NoBrokerFailsClosed(t *testing.T) {
bin := FormaeBinary(t)
- agent := StartAgent(t, bin, WithPluginDir(stagedOidcPluginDir(t, oidcPluginDirNoBrokerEnv)))
+ agent := StartAgent(t, bin,
+ WithPluginDir(stagedOidcPluginDir(t, oidcPluginDirNoBrokerEnv)),
+ // An audience, but no exchange coordinates: the plugin gets far enough
+ // to ask for a token, which is where this test's failure has to happen.
+ WithEnv("E2E_OIDC_AUDIENCE="+oidcEchoAudience),
+ )
echo := applyOidcEchoFixture(t, bin, agent)