Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

deployclient: add support for auto-renewing github tokens #296

Merged
merged 3 commits into from
Mar 21, 2025
Merged
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion actions/deploy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -67,8 +67,10 @@ if [ -z "$APIKEY" ]; then

payload=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=hookd")
jwt=$(echo "$payload" | jq -r '.value')

export GITHUB_TOKEN="$jwt"

export GITHUB_TOKEN_REQUEST_TOKEN="$ACTIONS_ID_TOKEN_REQUEST_TOKEN"
export GITHUB_TOKEN_REQUEST_URL="$ACTIONS_ID_TOKEN_REQUEST_URL"
else
echo "::notice ::APIKEY IS DEPRECATED, PLEASE USE WORKLOAD IDENTITY, For more info see https://doc.nais.io/build/how-to/build-and-deploy and/or https://docs.github.com/en/actions/using-jobs/assigning-permissions-to-jobs"
fi
11 changes: 8 additions & 3 deletions pkg/deployclient/config.go
Original file line number Diff line number Diff line change
@@ -19,7 +19,9 @@ type Config struct {
DeployServerURL string
DryRun bool
Environment string
GithubToken string
GitHubToken string
GitHubTokenURL string
GitHubBearerToken string
GrpcAuthentication bool
GrpcUseTLS bool
OpenTelemetryCollectorURL string
@@ -52,7 +54,9 @@ func InitConfig(cfg *Config) {
flag.StringVar(&cfg.DeployServerURL, "deploy-server", getEnv("DEPLOY_SERVER", DefaultDeployServer), "URL to API server. (env DEPLOY_SERVER)")
flag.BoolVar(&cfg.DryRun, "dry-run", getEnvBool("DRY_RUN", false), "Run templating, but don't actually make any requests. (env DRY_RUN)")
flag.StringVar(&cfg.Environment, "environment", os.Getenv("ENVIRONMENT"), "Environment for GitHub deployment. Autodetected from nais.yaml if not specified. (env ENVIRONMENT)")
flag.StringVar(&cfg.GithubToken, "github-token", os.Getenv("GITHUB_TOKEN"), "Github JWT. (env GITHUB_TOKEN)")
flag.StringVar(&cfg.GitHubToken, "github-token", os.Getenv("GITHUB_TOKEN"), "Deprecated. Use 'github-token-url' and 'github-bearer-token' instead. Github JWT. (env GITHUB_TOKEN)")
flag.StringVar(&cfg.GitHubTokenURL, "github-token-url", os.Getenv("GITHUB_TOKEN_URL"), "URL for requesting GitHub id_token. (env GITHUB_TOKEN_URL)")
flag.StringVar(&cfg.GitHubBearerToken, "github-bearer-token", os.Getenv("GITHUB_BEARER_TOKEN"), "Bearer token for use when requesting GitHub id_token. (env GITHUB_BEARER_TOKEN)")
flag.BoolVar(&cfg.GrpcAuthentication, "grpc-authentication", getEnvBool("GRPC_AUTHENTICATION", true), "Use team API key to authenticate requests. (env GRPC_AUTHENTICATION)")
flag.BoolVar(&cfg.GrpcUseTLS, "grpc-use-tls", getEnvBool("GRPC_USE_TLS", true), "Use encrypted connection for gRPC calls. (env GRPC_USE_TLS)")
flag.StringVar(&cfg.OpenTelemetryCollectorURL, "otel-collector-endpoint", getEnv("OTEL_COLLECTOR_ENDPOINT", DefaultOtelCollectorEndpoint), "OpenTelemetry collector endpoint. (env OTEL_COLLECTOR_ENDPOINT)")
@@ -139,7 +143,8 @@ func (cfg *Config) Validate() error {
return ErrClusterRequired
}

if len(cfg.APIKey) == 0 && len(cfg.GithubToken) == 0 {
githubAuth := len(cfg.GitHubToken) > 0 || (len(cfg.GitHubTokenURL) > 0 && len(cfg.GitHubBearerToken) > 0)
if len(cfg.APIKey) == 0 && !githubAuth {
return ErrAuthRequired
}

11 changes: 9 additions & 2 deletions pkg/deployclient/grpc.go
Original file line number Diff line number Diff line change
@@ -23,9 +23,16 @@ func NewGrpcConnection(cfg Config) (*grpc.ClientConn, error) {

if cfg.GrpcAuthentication {
var interceptor auth_interceptor.ClientInterceptor
if cfg.GithubToken != "" {
if cfg.GitHubBearerToken != "" && cfg.GitHubTokenURL != "" {
interceptor = &auth_interceptor.GitHubTokenInterceptor{
BearerToken: cfg.GitHubBearerToken,
RequireTLS: cfg.GrpcUseTLS,
TokenURL: cfg.GitHubTokenURL,
Team: cfg.Team,
}
} else if cfg.GitHubToken != "" {
interceptor = &auth_interceptor.JWTInterceptor{
JWT: cfg.GithubToken,
JWT: cfg.GitHubToken,
RequireTLS: cfg.GrpcUseTLS,
Team: cfg.Team,
}
86 changes: 86 additions & 0 deletions pkg/grpc/interceptor/auth/client.go
Original file line number Diff line number Diff line change
@@ -5,7 +5,14 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"

"github.com/lestrrat-go/jwx/v2/jwt"
)

type ClientInterceptor interface {
@@ -53,6 +60,85 @@ func (t *JWTInterceptor) RequireTransportSecurity() bool {
return t.RequireTLS
}

type GitHubTokenInterceptor struct {
BearerToken string
RequireTLS bool
TokenURL string
Team string

token string
tokenExpiresAt time.Time
mu sync.Mutex
}

func (g *GitHubTokenInterceptor) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
token, err := g.Token(ctx)
if err != nil {
return nil, fmt.Errorf("getting GitHub JWT: %w", err)
}

return map[string]string{
"jwt": token,
"team": g.Team,
}, nil
}

func (g *GitHubTokenInterceptor) RequireTransportSecurity() bool {
return g.RequireTLS
}

func (g *GitHubTokenInterceptor) Token(ctx context.Context) (string, error) {
g.mu.Lock()
defer g.mu.Unlock()

const renewBefore = 1 * time.Minute
shouldRenew := g.tokenExpiresAt.IsZero() || time.Now().After(g.tokenExpiresAt.Add(-renewBefore))
if g.token != "" && !shouldRenew {
return g.token, nil
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.TokenURL, nil)
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
q := req.URL.Query()
q.Add("audience", "hookd")
req.URL.RawQuery = q.Encode()
req.Header.Set("Authorization", "bearer "+g.BearerToken)

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetching token: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading body: %w", err)
}

var tokenResponse struct {
Token string `json:"value"`
}
err = json.Unmarshal(body, &tokenResponse)
if err != nil {
return "", fmt.Errorf("unmarshalling json: %w", err)
}

// Skip signature verification; we only care about the expiration time here.
// The receiving party (i.e., server) must verify the token anyway.
j, err := jwt.ParseString(tokenResponse.Token,
jwt.WithVerify(false),
)
if err != nil {
return "", fmt.Errorf("parsing JWT: %w", err)
}

g.token = tokenResponse.Token
g.tokenExpiresAt = j.Expiration()
return tokenResponse.Token, nil
}

func sign(data, key []byte) string {
hasher := hmac.New(sha256.New, key)
hasher.Write(data)