Skip to content

Commit 61731a8

Browse files
authored
deployclient: add support for auto-renewing github tokens (#296)
* deployclient: add support for auto-renewing github tokens Fixes #229. * grpc/interceptor: check success status code when fetching github token * actions/deploy: temporarily comment out new variables for testing
1 parent f9bc9a7 commit 61731a8

File tree

4 files changed

+110
-6
lines changed

4 files changed

+110
-6
lines changed

actions/deploy/entrypoint.sh

+3-1
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ if [ -z "$APIKEY" ]; then
6767

6868
payload=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=hookd")
6969
jwt=$(echo "$payload" | jq -r '.value')
70-
7170
export GITHUB_TOKEN="$jwt"
71+
72+
#export GITHUB_TOKEN_REQUEST_TOKEN="$ACTIONS_ID_TOKEN_REQUEST_TOKEN"
73+
#export GITHUB_TOKEN_REQUEST_URL="$ACTIONS_ID_TOKEN_REQUEST_URL"
7274
else
7375
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"
7476
fi

pkg/deployclient/config.go

+8-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ type Config struct {
1919
DeployServerURL string
2020
DryRun bool
2121
Environment string
22-
GithubToken string
22+
GitHubToken string
23+
GitHubTokenURL string
24+
GitHubBearerToken string
2325
GrpcAuthentication bool
2426
GrpcUseTLS bool
2527
OpenTelemetryCollectorURL string
@@ -52,7 +54,9 @@ func InitConfig(cfg *Config) {
5254
flag.StringVar(&cfg.DeployServerURL, "deploy-server", getEnv("DEPLOY_SERVER", DefaultDeployServer), "URL to API server. (env DEPLOY_SERVER)")
5355
flag.BoolVar(&cfg.DryRun, "dry-run", getEnvBool("DRY_RUN", false), "Run templating, but don't actually make any requests. (env DRY_RUN)")
5456
flag.StringVar(&cfg.Environment, "environment", os.Getenv("ENVIRONMENT"), "Environment for GitHub deployment. Autodetected from nais.yaml if not specified. (env ENVIRONMENT)")
55-
flag.StringVar(&cfg.GithubToken, "github-token", os.Getenv("GITHUB_TOKEN"), "Github JWT. (env GITHUB_TOKEN)")
57+
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)")
58+
flag.StringVar(&cfg.GitHubTokenURL, "github-token-url", os.Getenv("GITHUB_TOKEN_URL"), "URL for requesting GitHub id_token. (env GITHUB_TOKEN_URL)")
59+
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)")
5660
flag.BoolVar(&cfg.GrpcAuthentication, "grpc-authentication", getEnvBool("GRPC_AUTHENTICATION", true), "Use team API key to authenticate requests. (env GRPC_AUTHENTICATION)")
5761
flag.BoolVar(&cfg.GrpcUseTLS, "grpc-use-tls", getEnvBool("GRPC_USE_TLS", true), "Use encrypted connection for gRPC calls. (env GRPC_USE_TLS)")
5862
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 {
139143
return ErrClusterRequired
140144
}
141145

142-
if len(cfg.APIKey) == 0 && len(cfg.GithubToken) == 0 {
146+
githubAuth := len(cfg.GitHubToken) > 0 || (len(cfg.GitHubTokenURL) > 0 && len(cfg.GitHubBearerToken) > 0)
147+
if len(cfg.APIKey) == 0 && !githubAuth {
143148
return ErrAuthRequired
144149
}
145150

pkg/deployclient/grpc.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,16 @@ func NewGrpcConnection(cfg Config) (*grpc.ClientConn, error) {
2323

2424
if cfg.GrpcAuthentication {
2525
var interceptor auth_interceptor.ClientInterceptor
26-
if cfg.GithubToken != "" {
26+
if cfg.GitHubBearerToken != "" && cfg.GitHubTokenURL != "" {
27+
interceptor = &auth_interceptor.GitHubTokenInterceptor{
28+
BearerToken: cfg.GitHubBearerToken,
29+
RequireTLS: cfg.GrpcUseTLS,
30+
TokenURL: cfg.GitHubTokenURL,
31+
Team: cfg.Team,
32+
}
33+
} else if cfg.GitHubToken != "" {
2734
interceptor = &auth_interceptor.JWTInterceptor{
28-
JWT: cfg.GithubToken,
35+
JWT: cfg.GitHubToken,
2936
RequireTLS: cfg.GrpcUseTLS,
3037
Team: cfg.Team,
3138
}

pkg/grpc/interceptor/auth/client.go

+90
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import (
55
"crypto/hmac"
66
"crypto/sha256"
77
"encoding/hex"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"sync"
813
"time"
14+
15+
"github.com/lestrrat-go/jwx/v2/jwt"
916
)
1017

1118
type ClientInterceptor interface {
@@ -53,6 +60,89 @@ func (t *JWTInterceptor) RequireTransportSecurity() bool {
5360
return t.RequireTLS
5461
}
5562

63+
type GitHubTokenInterceptor struct {
64+
BearerToken string
65+
RequireTLS bool
66+
TokenURL string
67+
Team string
68+
69+
token string
70+
tokenExpiresAt time.Time
71+
mu sync.Mutex
72+
}
73+
74+
func (g *GitHubTokenInterceptor) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
75+
token, err := g.Token(ctx)
76+
if err != nil {
77+
return nil, fmt.Errorf("getting GitHub JWT: %w", err)
78+
}
79+
80+
return map[string]string{
81+
"jwt": token,
82+
"team": g.Team,
83+
}, nil
84+
}
85+
86+
func (g *GitHubTokenInterceptor) RequireTransportSecurity() bool {
87+
return g.RequireTLS
88+
}
89+
90+
func (g *GitHubTokenInterceptor) Token(ctx context.Context) (string, error) {
91+
g.mu.Lock()
92+
defer g.mu.Unlock()
93+
94+
const renewBefore = 1 * time.Minute
95+
shouldRenew := g.tokenExpiresAt.IsZero() || time.Now().After(g.tokenExpiresAt.Add(-renewBefore))
96+
if g.token != "" && !shouldRenew {
97+
return g.token, nil
98+
}
99+
100+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.TokenURL, nil)
101+
if err != nil {
102+
return "", fmt.Errorf("creating request: %w", err)
103+
}
104+
q := req.URL.Query()
105+
q.Add("audience", "hookd")
106+
req.URL.RawQuery = q.Encode()
107+
req.Header.Set("Authorization", "bearer "+g.BearerToken)
108+
109+
resp, err := http.DefaultClient.Do(req)
110+
if err != nil {
111+
return "", fmt.Errorf("fetching token: %w", err)
112+
}
113+
defer resp.Body.Close()
114+
115+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
116+
return "", fmt.Errorf("unexpected status code: %s", resp.Status)
117+
}
118+
119+
body, err := io.ReadAll(resp.Body)
120+
if err != nil {
121+
return "", fmt.Errorf("reading body: %w", err)
122+
}
123+
124+
var tokenResponse struct {
125+
Token string `json:"value"`
126+
}
127+
err = json.Unmarshal(body, &tokenResponse)
128+
if err != nil {
129+
return "", fmt.Errorf("unmarshalling json: %w", err)
130+
}
131+
132+
// Skip signature verification; we only care about the expiration time here.
133+
// The receiving party (i.e., server) must verify the token anyway.
134+
j, err := jwt.ParseString(tokenResponse.Token,
135+
jwt.WithVerify(false),
136+
)
137+
if err != nil {
138+
return "", fmt.Errorf("parsing JWT: %w", err)
139+
}
140+
141+
g.token = tokenResponse.Token
142+
g.tokenExpiresAt = j.Expiration()
143+
return tokenResponse.Token, nil
144+
}
145+
56146
func sign(data, key []byte) string {
57147
hasher := hmac.New(sha256.New, key)
58148
hasher.Write(data)

0 commit comments

Comments
 (0)