Skip to content
Draft
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
51 changes: 51 additions & 0 deletions cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
Expand Down Expand Up @@ -486,6 +487,20 @@ func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, err
Value: serpent.StringOf(&r.globalConfig),
Group: globalGroup,
},
{
Flag: "client-cert",
Env: "CODER_CLIENT_CERT_FILE",
Description: "Path to client certificate for mTLS authentication. Must be used with --client-key.",
Value: serpent.StringOf(&r.clientCertFile),
Group: globalGroup,
},
{
Flag: "client-key",
Env: "CODER_CLIENT_KEY_FILE",
Description: "Path to client private key for mTLS authentication. Must be used with --client-cert.",
Value: serpent.StringOf(&r.clientKeyFile),
Group: globalGroup,
},
{
Flag: "version",
// This was requested by a customer to assist with their migration.
Expand Down Expand Up @@ -520,6 +535,10 @@ type RootCmd struct {
disableNetworkTelemetry bool
noVersionCheck bool
noFeatureWarning bool

// mTLS client certificate configuration
clientCertFile string
clientKeyFile string
}

// InitClient authenticates the client with files from disk
Expand Down Expand Up @@ -648,6 +667,38 @@ func (r *RootCmd) configureClient(ctx context.Context, client *codersdk.Client,
if !r.noFeatureWarning {
transport = wrapTransportWithEntitlementsCheck(transport, inv.Stderr)
}

// Validate mTLS configuration
if (r.clientCertFile == "") != (r.clientKeyFile == "") {
return xerrors.Errorf("both --client-cert and --client-key must be provided together for mTLS authentication")
}

// Configure mTLS if client certificates are provided
if r.clientCertFile != "" && r.clientKeyFile != "" {
certificates, err := loadCertificates([]string{r.clientCertFile}, []string{r.clientKeyFile})
if err != nil {
return xerrors.Errorf("load mTLS certificates: %w", err)
}

tlsConfig := &tls.Config{
Certificates: certificates,
NextProtos: []string{"h2", "http/1.1"},
}

// Create a transport with mTLS config but use our wrapped transport
if httpTransport, ok := transport.(*http.Transport); ok {
// Clone the existing transport and add TLS config
newTransport := httpTransport.Clone()
newTransport.TLSClientConfig = tlsConfig
transport = newTransport
} else {
// Wrap the existing transport with a new one that has mTLS
transport = &http.Transport{
TLSClientConfig: tlsConfig,
}
}
}

headerTransport, err := r.HeaderTransport(ctx, serverURL)
if err != nil {
return xerrors.Errorf("create header transport: %w", err)
Expand Down
80 changes: 80 additions & 0 deletions cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,83 @@ func TestHandlersOK(t *testing.T) {

clitest.HandlersOK(t, cmd)
}

func TestMTLSValidation(t *testing.T) {
t.Parallel()

tests := []struct {
name string
clientCert string
clientKey string
wantErr bool
errContains string
}{
{
name: "both cert and key provided",
clientCert: "/path/to/cert.pem",
clientKey: "/path/to/key.pem",
wantErr: false,
},
{
name: "neither cert nor key provided",
clientCert: "",
clientKey: "",
wantErr: false,
},
{
name: "cert provided without key",
clientCert: "/path/to/cert.pem",
clientKey: "",
wantErr: true,
errContains: "both --client-cert and --client-key must be provided together",
},
{
name: "key provided without cert",
clientCert: "",
clientKey: "/path/to/key.pem",
wantErr: true,
errContains: "both --client-cert and --client-key must be provided together",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

// Create a test server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

args := []string{"login", srv.URL}
if tt.clientCert != "" {
args = append(args, "--client-cert", tt.clientCert)
}
if tt.clientKey != "" {
args = append(args, "--client-key", tt.clientKey)
}

inv, _ := clitest.New(t, args...)
err := inv.Run()

if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
require.Contains(t, err.Error(), tt.errContains)
}
} else if tt.clientCert != "" && tt.clientKey != "" {
// If both cert and key are provided, expect file not found error
// since we're using fake paths
require.Error(t, err)
require.Contains(t, err.Error(), "no such file or directory")
} else {
// Neither cert nor key provided, should not error due to mTLS validation
// (may error for other reasons like authentication)
if err != nil {
require.NotContains(t, err.Error(), "both --client-cert and --client-key must be provided together")
}
}
})
}
}