mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Adds opportunistic JWT parsing for values returned from the `--header-command`. If a JWT is parsed we use the value of its `exp` field to determine when we should re-run the command to refresh the values.
187 lines
4.8 KiB
Go
187 lines
4.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
"golang.org/x/sync/singleflight"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
// jwtExpirationSkew accounts for clock skew between the client and issuer.
|
|
const jwtExpirationSkew = 10 * time.Second
|
|
|
|
// headerTransport resolves initial headers so command errors surface at startup.
|
|
func headerTransport(ctx context.Context, serverURL *url.URL, header []string, headerCommand string) (*codersdk.HeaderTransport, error) {
|
|
headers, err := parseHeaders(header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var provider codersdk.HeaderProvider
|
|
if headerCommand == "" {
|
|
provider = codersdk.StaticHeaderProvider{Header: headers}
|
|
} else {
|
|
provider = &commandHeaderProvider{
|
|
serverURL: serverURL,
|
|
staticHeaders: headers,
|
|
command: headerCommand,
|
|
clock: quartz.NewReal(),
|
|
}
|
|
if _, err := provider.Headers(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &codersdk.HeaderTransport{
|
|
Transport: http.DefaultTransport,
|
|
Provider: provider,
|
|
}, nil
|
|
}
|
|
|
|
type commandHeaderProvider struct {
|
|
serverURL *url.URL
|
|
staticHeaders http.Header
|
|
command string
|
|
clock quartz.Clock
|
|
|
|
sf singleflight.Group
|
|
// Cache access is serialized inside sf.Do. Published maps are immutable.
|
|
cached http.Header
|
|
expires time.Time
|
|
}
|
|
|
|
func (p *commandHeaderProvider) Headers(ctx context.Context) (http.Header, error) {
|
|
value, err, _ := p.sf.Do("headers", func() (any, error) {
|
|
if p.cached != nil && (p.expires.IsZero() || p.clock.Now().Before(p.expires)) {
|
|
return p.cached.Clone(), nil
|
|
}
|
|
lines, err := runHeaderCommand(ctx, p.serverURL, p.command)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers, err := parseHeaders(lines)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.cached, p.expires = headers, headerExpiry(headers)
|
|
return headers.Clone(), nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
headers, ok := value.(http.Header)
|
|
if !ok {
|
|
return nil, xerrors.New("unexpected header cache result")
|
|
}
|
|
merged := p.staticHeaders.Clone()
|
|
if merged == nil {
|
|
merged = make(http.Header)
|
|
}
|
|
for name, values := range headers {
|
|
for _, value := range values {
|
|
merged.Add(name, value)
|
|
}
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
func runHeaderCommand(ctx context.Context, serverURL *url.URL, command string) ([]string, error) {
|
|
shell, caller := "sh", "-c"
|
|
if runtime.GOOS == "windows" {
|
|
shell, caller = "cmd.exe", "/c"
|
|
}
|
|
var stdout bytes.Buffer
|
|
// #nosec G204 - Executing the configured command is the purpose of this option.
|
|
cmd := exec.CommandContext(ctx, shell, caller, command)
|
|
cmd.Env = append(os.Environ(), "CODER_URL="+serverURL.String())
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = io.Discard
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, xerrors.Errorf("run header command: %w", err)
|
|
}
|
|
var lines []string
|
|
scanner := bufio.NewScanner(&stdout)
|
|
for scanner.Scan() {
|
|
lines = append(lines, scanner.Text())
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, xerrors.Errorf("scan header command output: %w", err)
|
|
}
|
|
return lines, nil
|
|
}
|
|
|
|
func parseHeaders(lines []string) (http.Header, error) {
|
|
headers := make(http.Header)
|
|
for i, line := range lines {
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
return nil, xerrors.Errorf("parse header %d: expected key=value", i+1)
|
|
}
|
|
headers.Add(key, value)
|
|
}
|
|
return headers, nil
|
|
}
|
|
|
|
// headerExpiry uses unverified JWT claims only to schedule refreshes. It does
|
|
// not authenticate the headers, which are passed to the server unchanged.
|
|
func headerExpiry(headers http.Header) time.Time {
|
|
var earliest time.Time
|
|
for _, values := range headers {
|
|
for _, value := range values {
|
|
if expiry, ok := jwtExpiry(value); ok && (earliest.IsZero() || expiry.Before(earliest)) {
|
|
earliest = expiry
|
|
}
|
|
}
|
|
}
|
|
if earliest.IsZero() {
|
|
return time.Time{}
|
|
}
|
|
return earliest.Add(-jwtExpirationSkew)
|
|
}
|
|
|
|
var headerJWTAlgorithms = []jose.SignatureAlgorithm{
|
|
jose.HS256, jose.HS384, jose.HS512,
|
|
jose.RS256, jose.RS384, jose.RS512,
|
|
jose.ES256, jose.ES384, jose.ES512,
|
|
jose.PS256, jose.PS384, jose.PS512,
|
|
jose.EdDSA,
|
|
}
|
|
|
|
func jwtExpiry(value string) (time.Time, bool) {
|
|
fields := strings.Fields(value)
|
|
switch {
|
|
case len(fields) == 1:
|
|
value = fields[0]
|
|
case len(fields) == 2 && strings.EqualFold(fields[0], "Bearer"):
|
|
value = fields[1]
|
|
default:
|
|
return time.Time{}, false
|
|
}
|
|
token, err := jwt.ParseSigned(value, headerJWTAlgorithms)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
var claims struct {
|
|
Expiry *jwt.NumericDate `json:"exp"`
|
|
}
|
|
// We don't have the issuer's key. exp is only a refresh hint, not proof
|
|
// of authentication.
|
|
if err := token.UnsafeClaimsWithoutVerification(&claims); err != nil || claims.Expiry == nil {
|
|
return time.Time{}, false
|
|
}
|
|
return claims.Expiry.Time(), true
|
|
}
|