From eeff64c25576b71d7da7d992816dbf4e3dce23e6 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 5 Aug 2026 17:23:08 +0400 Subject: [PATCH] feat: add live GitHub OIDC end-to-end test for AssumeRoleWithWebIdentity Add IAMAssumeRoleWithWebIdentity_github_oidc_live, the only web-identity test that exercises AssumeRoleWithWebIdentity against a real external OIDC provider end-to-end: GitHub Actions' own issuer, with real discovery-document fetch, JWKS fetch, RS256 signature verification, claims mapping, and session credential issuance. Every other web-identity test in the suite uses a fake token that never reaches real signature verification. The test registers a throwaway OIDC provider and trust role scoped to this repo (via a distinct test audience and repo-scoped sub condition), fetches a real ID token from GitHub's runtime endpoint, assumes the role, and confirms the issued session credentials work with a follow-up GetCallerIdentity call. It cleans up the role and provider unconditionally and skips itself when run outside a GitHub Actions job with id-token: write permission (e.g. local runs or fork PRs, where GitHub downgrades OIDC permissions to read-only). Add functional-iam-oidc.yml to run this test in CI on push to main and on same-repo pull_request runs, isolated from the full iam suite since it's the only test needing id-token: write. Add a SKIP counter and skipF() alongside the existing runF/passF/failF, and report it in the final RAN/PASS/FAIL summary, so a test opting out via skipF() (as this one does when OIDC env vars aren't present) is visible instead of silently absent from the count. --- .github/workflows/functional-iam-oidc.yml | 88 +++++++ cmd/versitygw/test.go | 4 +- tests/integration/group-tests.go | 2 + ...sume_role_with_web_identity_github_oidc.go | 242 ++++++++++++++++++ tests/integration/output.go | 15 +- 5 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/functional-iam-oidc.yml create mode 100644 tests/integration/iam_assume_role_with_web_identity_github_oidc.go diff --git a/.github/workflows/functional-iam-oidc.yml b/.github/workflows/functional-iam-oidc.yml new file mode 100644 index 00000000..0c7a11d9 --- /dev/null +++ b/.github/workflows/functional-iam-oidc.yml @@ -0,0 +1,88 @@ +name: IAM functional tests (GitHub OIDC live) + +# This workflow exercises AssumeRoleWithWebIdentity against a REAL external +# OIDC identity provider (GitHub Actions' own OIDC issuer) - the one publicly +# reachable, free IdP available from inside our own CI job, so no self-hosted +# IdP container is needed. +# +# Trigger stays plain `pull_request` (never pull_request_target or +# workflow_run) plus `push` to main. On a pull_request run, GitHub itself +# downgrades GITHUB_TOKEN/OIDC permissions to read-only whenever the PR +# comes from a fork - regardless of what this file requests - so +# ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN simply won't +# exist in that case and the test below skips itself. That's the actual +# security boundary here: a hostile fork-PR author cannot use their own PR +# to mint a token scoped to this repo's identity through this workflow. Only +# a same-repo (non-fork) pull_request run, or a push to main, gets real +# credentials and actually exercises the live OIDC flow. +permissions: + contents: read + id-token: write + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + name: RunIAMGitHubOIDCTest + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "stable" + id: go + + - name: Get Dependencies + run: | + go mod download + + - name: Build + run: | + make testbin + + - name: Run GitHub OIDC live web-identity test + run: | + set -Eeuo pipefail + + IAM_PID="" + cleanup() { + local status=$? + trap - EXIT + if [[ -n "$IAM_PID" ]] && kill -0 "$IAM_PID" 2>/dev/null; then + kill "$IAM_PID" 2>/dev/null || true + fi + if [[ -n "$IAM_PID" ]]; then + wait "$IAM_PID" 2>/dev/null || true + fi + exit "$status" + } + trap cleanup EXIT + + mkdir -p /tmp/iam-oidc + ./versitygw --health /healthz -p :7078 -a user -s pass iam --dir /tmp/iam-oidc & + IAM_PID=$! + + ready="" + for _ in {1..50}; do + if curl --fail --silent --max-time 1 http://127.0.0.1:7078/healthz >/dev/null 2>&1; then + ready=1 + break + fi + if ! kill -0 "$IAM_PID" 2>/dev/null; then + echo "IAM API server stopped before becoming ready" >&2 + exit 1 + fi + sleep 0.2 + done + if [[ -z "$ready" ]]; then + echo "timed out waiting for IAM API server" >&2 + exit 1 + fi + + ./versitygw test -a user -s pass -e http://127.0.0.1:7078 IAMAssumeRoleWithWebIdentity_github_oidc_live diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 7525fcfc..075305fe 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -420,7 +420,7 @@ func websiteHostingAction(ctx *cli.Context) error { ts.Wait() fmt.Println() - fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load()) + fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load(), "SKIP:", integration.SkipCount.Load()) if integration.FailCount.Load() > 0 { return fmt.Errorf("test failed with %v errors", integration.FailCount.Load()) } @@ -462,7 +462,7 @@ func getAction(tf testFunc) func(ctx *cli.Context) error { ts.Wait() fmt.Println() - fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load()) + fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load(), "SKIP:", integration.SkipCount.Load()) if integration.FailCount.Load() > 0 { return fmt.Errorf("test failed with %v errors", integration.FailCount.Load()) } diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index a21fd903..28ed43d7 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -1473,6 +1473,7 @@ func TestIAMAssumeRoleWithWebIdentity(ts *TestState) { ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch) ts.Run(IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch) ts.Run(IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch) + ts.Run(IAMAssumeRoleWithWebIdentity_github_oidc_live) } func TestIAMGetCallerIdentity(ts *TestState) { @@ -2199,6 +2200,7 @@ func GetIntTests() IntTests { "IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch, "IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch, "IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch, + "IAMAssumeRoleWithWebIdentity_github_oidc_live": IAMAssumeRoleWithWebIdentity_github_oidc_live, "IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success, "IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success, "IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key, diff --git a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go new file mode 100644 index 00000000..61a5600e --- /dev/null +++ b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go @@ -0,0 +1,242 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +const ( + // githubOIDCIssuerURL is GitHub Actions' own OIDC token issuer: a real, + // publicly reachable HTTPS endpoint with a CA-issued certificate. + githubOIDCIssuerURL = "https://token.actions.githubusercontent.com" + + // githubOIDCTestAudience is deliberately distinct from GitHub's default + // audience (which is the caller's own server URL). If this org ever + // configures a real cloud-provider role trusting + // token.actions.githubusercontent.com for this repo (e.g. for + // publishing/deploys), a leaked test token must not be replayable + // against that unrelated trust relationship - binding the throwaway + // role's trust policy to this audience (instead of GitHub's default) + // is what prevents that. + githubOIDCTestAudience = "versitygw-integration-tests" +) + +// IAMAssumeRoleWithWebIdentity_github_oidc_live exercises +// AssumeRoleWithWebIdentity against a REAL external OIDC identity provider — +// GitHub Actions' own OIDC issuer — end-to-end: discovery-document fetch, +// JWKS fetch, real RS256 signature verification, claims mapping, and +// session credential issuance. It's the only web-identity test that does +// this; every other one in this package uses a fake token that never +// reaches real signature verification. +func IAMAssumeRoleWithWebIdentity_github_oidc_live(s *S3Conf) error { + testName := "IAMAssumeRoleWithWebIdentity_github_oidc_live" + + reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if reqURL == "" || reqToken == "" { + skipF("%v: ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN not set "+ + "(expected outside a GitHub Actions job with id-token: write permission)", testName) + return nil + } + + return iamActionHandler(s, testName, func(client *iam.Client) error { + repo := os.Getenv("GITHUB_REPOSITORY") + if repo == "" { + return fmt.Errorf("GITHUB_REPOSITORY is not set, but ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN are - unexpected environment") + } + + roleName, roleArn, cleanup, err := createGitHubOIDCTrust(client, repo) + if err != nil { + return err + } + defer cleanup() + + token, err := fetchGitHubIDToken(reqURL, reqToken, githubOIDCTestAudience) + if err != nil { + return err + } + + const sessionName = "github-oidc-live" + assumeOut, err := assumeRoleWithWebIdentity(s, roleArn, sessionName, token, 0) + if err != nil { + // checkIAMApiErr-style wrapping isn't used here since a live + // AssumeRoleWithWebIdentity SDK error carries no token material + // of its own to guard against - it's the request we build + // (never printed) and GitHub's response (never printed either, + // see fetchGitHubIDToken) that could leak the token. + return fmt.Errorf("AssumeRoleWithWebIdentity: %w", err) + } + if assumeOut.Credentials == nil { + return fmt.Errorf("expected Credentials in AssumeRoleWithWebIdentity response") + } + accessKeyID := aws.ToString(assumeOut.Credentials.AccessKeyId) + secretAccessKey := aws.ToString(assumeOut.Credentials.SecretAccessKey) + sessionToken := aws.ToString(assumeOut.Credentials.SessionToken) + if accessKeyID == "" || secretAccessKey == "" || sessionToken == "" { + return fmt.Errorf("expected a full AccessKeyId/SecretAccessKey/SessionToken triple in AssumeRoleWithWebIdentity response") + } + + wantArn := fmt.Sprintf("arn:aws:sts::000000000000:assumed-role/%s/%s", roleName, sessionName) + if aws.ToString(assumeOut.AssumedRoleUser.Arn) != wantArn { + return fmt.Errorf("expected AssumedRoleUser.Arn %q, instead got %q", wantArn, aws.ToString(assumeOut.AssumedRoleUser.Arn)) + } + + // A follow-up call authenticated with the session credentials + // AssumeRoleWithWebIdentity just issued proves the whole chain - + // discovery, JWKS, signature verification, claims mapping, and + // session creds - actually works, not just that a 200 came back. + callerOut, err := getCallerIdentityWithSessionCreds(*s, accessKeyID, secretAccessKey, sessionToken) + if err != nil { + return fmt.Errorf("GetCallerIdentity with assumed-role session credentials: %w", err) + } + if aws.ToString(callerOut.Arn) != wantArn { + return fmt.Errorf("GetCallerIdentity: expected Arn %q, instead got %q", wantArn, aws.ToString(callerOut.Arn)) + } + return nil + }) +} + +// createGitHubOIDCTrust registers a throwaway OIDC provider for GitHub +// Actions' own issuer (ThumbprintList omitted, exercising +// CreateOpenIDConnectProvider's autofetch-and-CA-verify path against a real +// publicly reachable HTTPS endpoint instead of thumbprint pinning) and a +// throwaway role trusting it, returning the role's name, its ARN, and a +// cleanup func that removes both unconditionally. +// +// The trust policy's Condition requires both: +// - the effective audience to equal githubOIDCTestAudience (not GitHub's +// default audience - see that constant's doc comment), and +// - the sub claim to match "repo::*". +// +// The sub match is a repo-wide wildcard rather than pinning an exact +// ref/event suffix: GitHub's sub claim differs by trigger and branch (e.g. +// "repo:o/r:pull_request" for a pull_request event vs. +// "repo:o/r:ref:refs/heads/main" for a push to main), and pinning one exact +// form would make this test fail depending on how it was triggered. That +// tradeoff only holds because this role is created and deleted within a +// single test run - the same repo-wide wildcard left in a real production +// trust policy would grant every workflow run in the repo, on any branch, +// the same trust, which is far too broad outside this throwaway context. +func createGitHubOIDCTrust(client *iam.Client, repo string) (roleName, roleArn string, cleanup func(), err error) { + out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{ + Url: aws.String(githubOIDCIssuerURL), + ClientIDList: []string{githubOIDCTestAudience}, + }) + if err != nil { + return "", "", nil, fmt.Errorf("create GitHub OIDC provider: %w", err) + } + providerArn := aws.ToString(out.OpenIDConnectProviderArn) + + host := trimProviderScheme(githubOIDCIssuerURL) + roleName = "github-oidc-" + genRandString(12) + trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+ + `"Condition":{"StringEquals":{"%s:aud":%q},"StringLike":{"%s:sub":%q}}}]}`, + providerArn, host, githubOIDCTestAudience, host, "repo:"+repo+":*") + if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil { + deleteOIDCProvider(client, providerArn) + return "", "", nil, fmt.Errorf("create GitHub OIDC trust role: %w", err) + } + + roleArn = "arn:aws:iam::000000000000:role/" + roleName + cleanup = func() { + deleteIAMRole(client, roleName) + deleteOIDCProvider(client, providerArn) + } + return roleName, roleArn, cleanup, nil +} + +// githubIDTokenResponse is the JSON body GitHub's runtime ID-token endpoint +// returns: {"value": "", "count": }. Only value is needed here. +type githubIDTokenResponse struct { + Value string `json:"value"` +} + +// fetchGitHubIDToken fetches a real, signed OIDC ID token for audience from +// GitHub Actions' runtime token endpoint (requestURL/requestToken are +// ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN, only present +// inside a GitHub Actions job with id-token: write permission). +// +// The returned token is a real, unmasked bearer credential - unlike a +// secrets.* value, GitHub does not scrub it from logs automatically since it +// never appears in the workflow YAML. Every error path here is deliberately +// built from fixed strings and status codes only, never from the response +// body or the request's Authorization header, so a failure here can never +// leak the token into CI output. +func fetchGitHubIDToken(requestURL, requestToken, audience string) (string, error) { + parsed, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("parse ACTIONS_ID_TOKEN_REQUEST_URL: invalid URL") + } + q := parsed.Query() + q.Set("audience", audience) + parsed.RawQuery = q.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return "", fmt.Errorf("build GitHub OIDC token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+requestToken) + req.Header.Set("Accept", "application/json; api-version=2.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch GitHub OIDC token: request failed") + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("read GitHub OIDC token response: failed after status %d", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub OIDC token endpoint returned status %d", resp.StatusCode) + } + + var out githubIDTokenResponse + if err := json.Unmarshal(body, &out); err != nil { + return "", fmt.Errorf("parse GitHub OIDC token response: malformed JSON") + } + if out.Value == "" { + return "", fmt.Errorf("GitHub OIDC token endpoint returned an empty token value") + } + return out.Value, nil +} + +// getCallerIdentityWithSessionCreds calls GetCallerIdentity authenticated +// with a full access/secret/session-token triple. +func getCallerIdentityWithSessionCreds(cfg S3Conf, access, secret, token string) (*sts.GetCallerIdentityOutput, error) { + cfg.awsID = access + cfg.awsSecret = secret + stsCfg := cfg.Config() + stsCfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token) + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + defer cancel() + return sts.NewFromConfig(stsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} diff --git a/tests/integration/output.go b/tests/integration/output.go index a0b295f4..0d3506c2 100644 --- a/tests/integration/output.go +++ b/tests/integration/output.go @@ -20,16 +20,18 @@ import ( ) var ( - colorReset = "\033[0m" - colorRed = "\033[31m" - colorGreen = "\033[32m" - colorCyan = "\033[36m" + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorCyan = "\033[36m" + colorYellow = "\033[33m" ) var ( RunCount atomic.Uint32 PassCount atomic.Uint32 FailCount atomic.Uint32 + SkipCount atomic.Uint32 ) func runF(format string, a ...any) { @@ -46,3 +48,8 @@ func passF(format string, a ...any) { PassCount.Add(1) fmt.Printf(colorGreen+"PASS "+colorReset+format+"\n", a...) } + +func skipF(format string, a ...any) { + SkipCount.Add(1) + fmt.Printf(colorYellow+"SKIP "+colorReset+format+"\n", a...) +}