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
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion actions/deploy/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions pkg/deployclient/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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
}

Expand Down
11 changes: 9 additions & 2 deletions pkg/deployclient/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
90 changes: 90 additions & 0 deletions pkg/grpc/interceptor/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -53,6 +60,89 @@ 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()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("unexpected status code: %s", resp.Status)
}

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)
Expand Down