mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 12:46:23 +00:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:<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": "<jwt>", "count": <n>}. 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{})
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user