Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This library provides a unified interface for obtaining and refreshing credentia
* [Conjur Provider](#conjur-credentials-provider)
* [GitHub App Provider](#github-app-credentials-provider)
* [GitHub Actions Identity Token Provider](#github-actions-identity-token-provider)
* [SPIFFE Identity Token Provider](#spiffe-identity-token-provider)
* [Channel Behavior](#channel-behavior)
* [License](#license)
* [Contributing](#contributing)
Expand All @@ -48,6 +49,7 @@ This library provides a unified interface for obtaining and refreshing credentia
- **Conjur:** Exchanges ID tokens for secrets from CyberArk Conjur (Secrets Manager Self-Hosted and SaaS) using Conjur's JWT authenticator, and polls for rotated values.
- **GitHub App:** Mints GitHub App installation access tokens by signing a JWT with the App's private key and refreshes them before expiration.
- **GitHub Actions:** Fetches OIDC ID tokens from the GitHub Actions runtime for the providers that exchange an identity token (AWS, GCP, Azure, OCI, Vault, Conjur, Generic, RFC 7523 and RFC 8693).
- **SPIFFE:** Fetches JWT-SVIDs from the SPIFFE Workload API (e.g. a SPIRE agent) for the same providers.

## Installation

Expand Down Expand Up @@ -891,6 +893,54 @@ for cred := range credCh {
}
```

### SPIFFE Identity Token Provider

Fetches JWT-SVIDs from the SPIFFE Workload API (e.g. a SPIRE agent), so a workload can pass them to any provider that takes an `IdentityTokenProvider`. Each `GetToken` call asks the Workload API, so refreshes always use the SVID the agent currently serves.

The example exchanges the SVID for AWS credentials. AWS needs an IAM OIDC identity provider for SPIRE's OIDC Discovery Provider, and the SVID audience must be `sts.amazonaws.com`:

```go
import (
"go.riptides.io/tokenex/pkg/aws"
"go.riptides.io/tokenex/pkg/credential"
"go.riptides.io/tokenex/pkg/token/spiffe"
awssdk "github.com/aws/aws-sdk-go-v2/aws"
)

// Reads the Workload API address from SPIFFE_ENDPOINT_SOCKET. Use WithAddr to set it explicitly.
idTokenProvider, err := spiffe.NewIdentityTokenProvider(
// Must match the audience of the IAM OIDC identity provider.
spiffe.WithAudience("sts.amazonaws.com"),
)
if err != nil {
log.Fatalf("new provider: %v", err)
}

awsProvider, err := aws.NewCredentialsProvider(ctx, logr.Discard(), &awssdk.Config{Region: "us-east-1"})
if err != nil {
log.Fatalf("new AWS provider: %v", err)
}

credCh, err := awsProvider.GetCredentials(ctx, idTokenProvider,
aws.WithRoleArn("arn:aws:iam::123456789012:role/spire-demo"),
aws.WithRoleSessionName("spire-demo"),
)
if err != nil {
log.Fatalf("get credentials: %v", err)
}

for cred := range credCh {
if cred.Err != nil {
log.Printf("aws credentials error: %v", cred.Err)
break
}
creds := cred.Credential.(*credential.AWSCreds)
log.Printf("got AWS credentials %s, expire %s", creds.AccessKeyID, creds.Expires)
}
```

Use `WithSPIFFEID` to pick an SVID when the workload has more than one identity, and `WithSource` to reuse a `workloadapi.Client` or `workloadapi.JWTSource` you already have. [examples/spire-aws](examples/spire-aws) is a runnable version with the AWS setup steps.

## Channel Behavior

All credential providers in this library follow a consistent pattern for credential delivery:
Expand Down
36 changes: 36 additions & 0 deletions examples/spire-aws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# SPIRE → AWS

Exchanges the workload's JWT-SVID for AWS credentials with `AssumeRoleWithWebIdentity`, then prints the assumed role on every refresh.

## AWS setup

1. Expose the [SPIRE OIDC Discovery Provider](https://github.com/spiffe/spire/tree/main/support/oidc-discovery-provider) over public HTTPS, and set the SPIRE server's `jwt_issuer` to the same URL so the SVIDs' `iss` claim matches.
2. Create an IAM OIDC identity provider for that URL with audience `sts.amazonaws.com`.
3. Create a role that trusts it, scoped to the workload's SPIFFE ID:

```json
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.example.org" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.example.org:aud": "sts.amazonaws.com",
"oidc.example.org:sub": "spiffe://example.org/ns/default/sa/demo"
}
}
}
```

See the [spiffe.io AWS OIDC tutorial](https://spiffe.io/docs/latest/keyless/oidc-federation-aws/) for a full walkthrough.

## Run

```bash
SPIFFE_ENDPOINT_SOCKET=unix:///run/spire/agent-sockets/spire-agent.sock \
AWS_ROLE_ARN=arn:aws:iam::123456789012:role/spire-demo \
AWS_REGION=us-east-1 \
go run ./examples/spire-aws
```

In Kubernetes with the SPIFFE CSI driver, point `SPIFFE_ENDPOINT_SOCKET` at the mounted socket, e.g. `unix:///spiffe-workload-api/spire-agent.sock`.
89 changes: 89 additions & 0 deletions examples/spire-aws/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 Riptides Labs, Inc.
// SPDX-License-Identifier: MIT

// Command spire-aws exchanges the workload's JWT-SVID for AWS credentials and prints the assumed identity
// on every refresh.
package main

import (
"context"
"log"
"os"
"os/signal"
"syscall"

"emperror.dev/errors"
awssdk "github.com/aws/aws-sdk-go-v2/aws"
awscreds "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/go-logr/logr"

"go.riptides.io/tokenex/pkg/aws"
"go.riptides.io/tokenex/pkg/credential"
"go.riptides.io/tokenex/pkg/token/spiffe"
)

func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}

func run() error {
roleArn := os.Getenv("AWS_ROLE_ARN")
if roleArn == "" {
return errors.New("AWS_ROLE_ARN is required")
}

region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-1"
}

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

// Reads the Workload API address from SPIFFE_ENDPOINT_SOCKET.
idTokenProvider, err := spiffe.NewIdentityTokenProvider(spiffe.WithAudience("sts.amazonaws.com"))
if err != nil {
return errors.WrapIf(err, "could not create SPIFFE identity token provider")
}

awsProvider, err := aws.NewCredentialsProvider(ctx, logr.Discard(), &awssdk.Config{Region: region})
if err != nil {
return errors.WrapIf(err, "could not create AWS credentials provider")
}

credsCh, err := awsProvider.GetCredentials(ctx, idTokenProvider,
aws.WithRoleArn(roleArn),
aws.WithRoleSessionName("spire-aws-example"),
)
if err != nil {
return errors.WrapIf(err, "could not get AWS credentials")
}

for res := range credsCh {
if res.Err != nil {
return errors.WrapIf(res.Err, "credentials refresh failed")
}

creds, ok := res.Credential.(*credential.AWSCreds)
if !ok {
continue
}

stsClient := sts.New(sts.Options{
Region: region,
Credentials: awscreds.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken),
})

identity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
if err != nil {
return errors.WrapIf(err, "GetCallerIdentity failed")
}

log.Printf("assumed %s, credentials expire at %s", awssdk.ToString(identity.Arn), creds.Expires)
}

return nil
}
34 changes: 18 additions & 16 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,27 @@ require (
github.com/google/go-github/v66 v66.0.0
github.com/openbao/openbao/api/auth/jwt/v2 v2.5.0
github.com/openbao/openbao/api/v2 v2.5.0
github.com/stretchr/testify v1.11.1
github.com/spiffe/go-spiffe/v2 v2.9.0
github.com/stretchr/testify v1.12.1
github.com/werbenhu/eventbus v1.0.8
golang.org/x/oauth2 v0.30.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.240.0
google.golang.org/protobuf v1.36.6
google.golang.org/protobuf v1.36.12
k8s.io/api v0.33.0
k8s.io/client-go v0.33.0
sigs.k8s.io/controller-runtime v0.21.0
)

require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
)

require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
Expand All @@ -53,7 +55,7 @@ require (
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
Expand Down Expand Up @@ -94,19 +96,19 @@ require (
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apimachinery v0.33.0 // indirect
k8s.io/apimachinery v0.33.0
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
Expand Down
Loading
Loading