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 @@ -27,6 +27,7 @@ This library provides a unified interface for obtaining and refreshing credentia
* [Vault Provider](#vault-credentials-provider)
* [Conjur Provider](#conjur-credentials-provider)
* [GitHub App Provider](#github-app-credentials-provider)
* [GitHub Actions Identity Token Provider](#github-actions-identity-token-provider)
* [Channel Behavior](#channel-behavior)
* [License](#license)
* [Contributing](#contributing)
Expand All @@ -46,6 +47,7 @@ This library provides a unified interface for obtaining and refreshing credentia
- **Vault** Exchanges ID tokens for secrets from Vault using Vault's JWT authentication.
- **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).

## Installation

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

### GitHub Actions Identity Token Provider

Fetches OIDC ID tokens from the GitHub Actions runtime, so a workflow can pass them to any provider that takes an `IdentityTokenProvider` (AWS, GCP, Azure, OCI, Vault, Conjur, Generic, RFC 7523 and RFC 8693). The job needs the `id-token: write` permission:

```yaml
permissions:
id-token: write
```

The example exchanges the token for an Anthropic access token via Workload Identity Federation:

```go
import (
"go.riptides.io/tokenex/pkg/credential"
"go.riptides.io/tokenex/pkg/rfc7523"
"go.riptides.io/tokenex/pkg/token/githubactions"
)

// Reads ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN from the environment.
// Use WithRequestURL / WithRequestToken where the runner's environment isn't inherited, e.g. in a container.
idTokenProvider, err := githubactions.NewIdentityTokenProvider(
// Must match the audience the federation rule expects.
githubactions.WithAudience("api.anthropic.com"),
)
if err != nil {
log.Fatalf("new provider: %v", err)
}

credCh, err := rfc7523.GetAnthropicCredentials(ctx, logr.Discard(), idTokenProvider, rfc7523.AnthropicWIFConfig{
FederationRuleID: "fdrl_...",
OrganizationID: "...",
ServiceAccountID: "...",
WorkspaceID: "...",
})
if err != nil {
log.Fatalf("get credentials: %v", err)
}

for cred := range credCh {
if cred.Err != nil {
log.Printf("anthropic token error: %v", cred.Err)
break
}
tok := cred.Credential.(*credential.Oauth2Creds)
log.Printf("got anthropic access token, expires %s", tok.Expiry)
}
```

## Channel Behavior

All credential providers in this library follow a consistent pattern for credential delivery:
Expand Down
182 changes: 182 additions & 0 deletions pkg/token/githubactions/githubactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Riptides Labs, Inc.
// SPDX-License-Identifier: MIT

// Package githubactions provides an IdentityTokenProvider that fetches OIDC ID tokens from the
// GitHub Actions runtime, so a workflow can exchange them with any tokenex credentials provider.
// The job needs the `id-token: write` permission.
package githubactions

import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"time"

"emperror.dev/errors"
"github.com/golang-jwt/jwt/v5"

"go.riptides.io/tokenex/pkg/credential"
"go.riptides.io/tokenex/pkg/option"
"go.riptides.io/tokenex/pkg/token"
)

const (
requestURLEnvVar = "ACTIONS_ID_TOKEN_REQUEST_URL"
requestTokenEnvVar = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"

// minValidity is how long a cached token must remain valid to be returned instead of fetching a new one.
minValidity = time.Minute
)

type providerConfig struct {
audience string
requestURL string
requestToken string
httpClient *http.Client
}

// IdentityTokenProvider fetches GitHub Actions OIDC ID tokens and caches them until shortly before they expire.
type IdentityTokenProvider struct {
requestURL string
requestToken string
httpClient *http.Client

// lock serializes fetches. It is a channel rather than a mutex so waiting callers can honor ctx cancellation.
lock chan struct{}
token credential.Token
}

var _ token.IdentityTokenProvider = (*IdentityTokenProvider)(nil)

// NewIdentityTokenProvider creates a provider for GitHub Actions OIDC ID tokens.
// The request URL and bearer token default to the ACTIONS_ID_TOKEN_REQUEST_URL and
// ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variables, which the runner sets for jobs
// with the `id-token: write` permission.
func NewIdentityTokenProvider(opts ...option.Option) (*IdentityTokenProvider, error) {
cfg := &providerConfig{
requestURL: os.Getenv(requestURLEnvVar),
requestToken: os.Getenv(requestTokenEnvVar),
}

for _, opt := range opts {
if o, ok := isProviderOption(opt); ok {
o.Apply(cfg)
}
}

if cfg.requestURL == "" {
return nil, errors.Errorf("%s is not set (the job needs the id-token: write permission)", requestURLEnvVar)
}

if cfg.requestToken == "" {
return nil, errors.Errorf("%s is not set (the job needs the id-token: write permission)", requestTokenEnvVar)
}

u, err := url.Parse(cfg.requestURL)
if err != nil {
return nil, errors.WrapIf(err, "could not parse token request URL")
}

if cfg.audience != "" {
q := u.Query()
q.Set("audience", cfg.audience)
u.RawQuery = q.Encode()
}

httpClient := cfg.httpClient
if httpClient == nil {
httpClient = http.DefaultClient
}

return &IdentityTokenProvider{
requestURL: u.String(),
requestToken: cfg.requestToken,
httpClient: httpClient,
lock: make(chan struct{}, 1),
}, nil
}

// GetToken returns the cached ID token, fetching a new one if it is missing or about to expire.
func (p *IdentityTokenProvider) GetToken(ctx context.Context, _ ...option.Option) (credential.Token, error) {
select {
case p.lock <- struct{}{}:
case <-ctx.Done():
return credential.Token{}, errors.WithStackIf(ctx.Err())
}

defer func() { <-p.lock }()

if p.token.Token != "" && time.Until(p.token.ExpiresAt) > minValidity {
return p.token, nil
}

tok, err := p.fetchToken(ctx)
if err != nil {
return credential.Token{}, err
}

p.token = tok

return tok, nil
}

type tokenResponse struct {
Value string `json:"value"`
}

func (p *IdentityTokenProvider) fetchToken(ctx context.Context) (credential.Token, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.requestURL, nil)
if err != nil {
return credential.Token{}, errors.WrapIf(err, "could not build token request")
}

req.Header.Set("Authorization", "Bearer "+p.requestToken)
req.Header.Set("Accept", "application/json; api-version=2.0")

resp, err := p.httpClient.Do(req)
if err != nil {
return credential.Token{}, errors.WrapIf(err, "token request failed")
}

defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return credential.Token{}, errors.WrapIf(err, "could not read token response body")
}

if resp.StatusCode != http.StatusOK {
return credential.Token{}, errors.Errorf("token request returned status %d: %s", resp.StatusCode, string(body))
}

var tokenResp tokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return credential.Token{}, errors.WrapIf(err, "could not parse token response")
}

if tokenResp.Value == "" {
return credential.Token{}, errors.New("token response has no value")
}

t, _, err := jwt.NewParser().ParseUnverified(tokenResp.Value, jwt.MapClaims{})
if err != nil {
return credential.Token{}, errors.WrapIf(err, "could not parse ID token")
}

exp, err := t.Claims.GetExpirationTime()
if err != nil {
return credential.Token{}, errors.WrapIf(err, "could not get ID token expiration time")
}

if exp == nil {
return credential.Token{}, errors.New("ID token has no exp claim")
}

return credential.Token{
Token: tokenResp.Value,
ExpiresAt: exp.Time,
}, nil
}
Loading
Loading