mirror of
https://github.com/coder/coder.git
synced 2026-10-29 17:44:33 +08:00
feat: Add AWS instance identity authentication (#570)
* feat: Add AWS instance identity authentication This allows zero-trust authentication for all AWS instances. Prior to this, AWS instances could be used by passing `CODER_TOKEN` as an environment variable to the startup script. AWS explicitly states that secrets should not be passed in startup scripts because it's user-readable. * Fix sha256 verbosity * Fix HTTP client being exposed on auth
This commit is contained in:
@@ -90,6 +90,5 @@ site/out:
|
|||||||
.PHONY: site/out
|
.PHONY: site/out
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@echo "--- golangci-lint"
|
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
|||||||
signal.Notify(interrupt, os.Interrupt)
|
signal.Notify(interrupt, os.Interrupt)
|
||||||
defer signal.Stop(interrupt)
|
defer signal.Stop(interrupt)
|
||||||
|
|
||||||
errCh := make(chan error)
|
errCh := make(chan error, 1)
|
||||||
lineCh := make(chan string)
|
lineCh := make(chan string)
|
||||||
go func() {
|
go func() {
|
||||||
var line string
|
var line string
|
||||||
|
|||||||
+36
-8
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,6 +40,11 @@ func workspaceAgent() *cobra.Command {
|
|||||||
}
|
}
|
||||||
logger := slog.Make(sloghuman.Sink(cmd.OutOrStdout())).Leveled(slog.LevelDebug)
|
logger := slog.Make(sloghuman.Sink(cmd.OutOrStdout())).Leveled(slog.LevelDebug)
|
||||||
client := codersdk.New(coderURL)
|
client := codersdk.New(coderURL)
|
||||||
|
|
||||||
|
// exchangeToken returns a session token.
|
||||||
|
// This is abstracted to allow for the same looping condition
|
||||||
|
// regardless of instance identity auth type.
|
||||||
|
var exchangeToken func(context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error)
|
||||||
switch auth {
|
switch auth {
|
||||||
case "token":
|
case "token":
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -53,29 +59,51 @@ func workspaceAgent() *cobra.Command {
|
|||||||
if gcpClientRaw != nil {
|
if gcpClientRaw != nil {
|
||||||
gcpClient, _ = gcpClientRaw.(*metadata.Client)
|
gcpClient, _ = gcpClientRaw.(*metadata.Client)
|
||||||
}
|
}
|
||||||
|
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||||
|
return client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient)
|
||||||
|
}
|
||||||
|
case "aws-instance-identity":
|
||||||
|
// This is *only* done for testing to mock client authentication.
|
||||||
|
// This will never be set in a production scenario.
|
||||||
|
var awsClient *http.Client
|
||||||
|
awsClientRaw := cmd.Context().Value("aws-client")
|
||||||
|
if awsClientRaw != nil {
|
||||||
|
awsClient, _ = awsClientRaw.(*http.Client)
|
||||||
|
if awsClient != nil {
|
||||||
|
client.HTTPClient = awsClient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) {
|
||||||
|
return client.AuthWorkspaceAWSInstanceIdentity(ctx)
|
||||||
|
}
|
||||||
|
case "azure-instance-identity":
|
||||||
|
return xerrors.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancelFunc := context.WithTimeout(cmd.Context(), 30*time.Second)
|
if exchangeToken != nil {
|
||||||
|
// Agent's can start before resources are returned from the provisioner
|
||||||
|
// daemon. If there are many resources being provisioned, this time
|
||||||
|
// could be significant. This is arbitrarily set at an hour to prevent
|
||||||
|
// tons of idle agents from pinging coderd.
|
||||||
|
ctx, cancelFunc := context.WithTimeout(cmd.Context(), time.Hour)
|
||||||
defer cancelFunc()
|
defer cancelFunc()
|
||||||
for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) {
|
for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) {
|
||||||
var response codersdk.WorkspaceAgentAuthenticateResponse
|
var response codersdk.WorkspaceAgentAuthenticateResponse
|
||||||
|
|
||||||
response, err = client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient)
|
response, err = exchangeToken(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn(ctx, "authenticate workspace with Google Instance Identity", slog.Error(err))
|
logger.Warn(ctx, "authenticate workspace", slog.F("method", auth), slog.Error(err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
client.SessionToken = response.SessionToken
|
client.SessionToken = response.SessionToken
|
||||||
logger.Info(ctx, "authenticated with Google Instance Identity")
|
logger.Info(ctx, "authenticated", slog.F("method", auth))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("agent failed to authenticate in time: %w", err)
|
return xerrors.Errorf("agent failed to authenticate in time: %w", err)
|
||||||
}
|
}
|
||||||
case "aws-instance-identity":
|
|
||||||
return xerrors.Errorf("not implemented")
|
|
||||||
case "azure-instance-identity":
|
|
||||||
return xerrors.Errorf("not implemented")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{
|
closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,12 +14,66 @@ import (
|
|||||||
|
|
||||||
func TestWorkspaceAgent(t *testing.T) {
|
func TestWorkspaceAgent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
t.Run("AWS", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
instanceID := "instanceidentifier"
|
||||||
|
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
|
||||||
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
|
AWSInstanceIdentity: certificates,
|
||||||
|
})
|
||||||
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
coderdtest.NewProvisionerDaemon(t, client)
|
||||||
|
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
|
Parse: echo.ParseComplete,
|
||||||
|
Provision: []*proto.Provision_Response{{
|
||||||
|
Type: &proto.Provision_Response_Complete{
|
||||||
|
Complete: &proto.Provision_Complete{
|
||||||
|
Resources: []*proto.Resource{{
|
||||||
|
Name: "somename",
|
||||||
|
Type: "someinstance",
|
||||||
|
Agent: &proto.Agent{
|
||||||
|
Auth: &proto.Agent_InstanceId{
|
||||||
|
InstanceId: instanceID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
|
||||||
|
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
|
||||||
|
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||||
|
|
||||||
|
cmd, _ := clitest.New(t, "workspaces", "agent", "--auth", "aws-instance-identity", "--url", client.URL.String())
|
||||||
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||||
|
defer cancelFunc()
|
||||||
|
go func() {
|
||||||
|
// A linting error occurs for weakly typing the context value here,
|
||||||
|
// but it seems reasonable for a one-off test.
|
||||||
|
// nolint
|
||||||
|
ctx = context.WithValue(ctx, "aws-client", metadataClient)
|
||||||
|
err := cmd.ExecuteContext(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||||
|
resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer dialer.Close()
|
||||||
|
_, err = dialer.Ping()
|
||||||
|
require.NoError(t, err)
|
||||||
|
cancelFunc()
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("GoogleCloud", func(t *testing.T) {
|
t.Run("GoogleCloud", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
instanceID := "instanceidentifier"
|
instanceID := "instanceidentifier"
|
||||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
GoogleTokenValidator: validator,
|
GoogleInstanceIdentity: validator,
|
||||||
})
|
})
|
||||||
user := coderdtest.CreateFirstUser(t, client)
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
coderdtest.NewProvisionerDaemon(t, client)
|
coderdtest.NewProvisionerDaemon(t, client)
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package awsidentity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Region represents the AWS locations a public-key covers.
|
||||||
|
type Region string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Other Region = "other"
|
||||||
|
HongKong Region = "hongkong"
|
||||||
|
Bahrain Region = "bahrain"
|
||||||
|
CapeTown Region = "capetown"
|
||||||
|
Milan Region = "milan"
|
||||||
|
China Region = "china"
|
||||||
|
GovCloud Region = "govcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
All = []Region{Other, HongKong, Bahrain, CapeTown, Milan, China, GovCloud}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificates hold public keys for various AWS regions. See:
|
||||||
|
type Certificates map[Region]string
|
||||||
|
|
||||||
|
// Identity represents a validated document and signature.
|
||||||
|
type Identity struct {
|
||||||
|
InstanceID string
|
||||||
|
Region Region
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
|
||||||
|
type awsInstanceIdentityDocument struct {
|
||||||
|
InstanceID string `json:"instanceId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ensures the document was signed by an AWS public key.
|
||||||
|
// Regions that aren't provided in certificates will use defaults.
|
||||||
|
func Validate(signature, document string, certificates Certificates) (Identity, error) {
|
||||||
|
if certificates == nil {
|
||||||
|
certificates = Certificates{}
|
||||||
|
}
|
||||||
|
for _, region := range All {
|
||||||
|
if _, ok := certificates[region]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defaultCertificate, exists := defaultCertificates[region]
|
||||||
|
if !exists {
|
||||||
|
panic("dev error: no certificate exists for region " + region)
|
||||||
|
}
|
||||||
|
certificates[region] = defaultCertificate
|
||||||
|
}
|
||||||
|
|
||||||
|
var instanceIdentity awsInstanceIdentityDocument
|
||||||
|
err := json.Unmarshal([]byte(document), &instanceIdentity)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, xerrors.Errorf("parse document: %w", err)
|
||||||
|
}
|
||||||
|
rawSignature, err := base64.StdEncoding.DecodeString(signature)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, xerrors.Errorf("decode signature: %w", err)
|
||||||
|
}
|
||||||
|
hashedDocument := sha256.Sum256([]byte(document))
|
||||||
|
|
||||||
|
for region, certificate := range certificates {
|
||||||
|
regionBlock, rest := pem.Decode([]byte(certificate))
|
||||||
|
if len(rest) != 0 {
|
||||||
|
return Identity{}, xerrors.Errorf("invalid certificate for %q. %d bytes remain", region, len(rest))
|
||||||
|
}
|
||||||
|
regionCert, err := x509.ParseCertificate(regionBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, xerrors.Errorf("parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
regionPublicKey, valid := regionCert.PublicKey.(*rsa.PublicKey)
|
||||||
|
if !valid {
|
||||||
|
return Identity{}, xerrors.Errorf("certificate for %q was not an rsa key", region)
|
||||||
|
}
|
||||||
|
err = rsa.VerifyPKCS1v15(regionPublicKey, crypto.SHA256, hashedDocument[:], rawSignature)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return Identity{
|
||||||
|
InstanceID: instanceIdentity.InstanceID,
|
||||||
|
Region: region,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return Identity{}, rsa.ErrVerification
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default AWS certificates for regions.
|
||||||
|
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/verify-signature.html
|
||||||
|
var defaultCertificates = Certificates{
|
||||||
|
Other: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDIjCCAougAwIBAgIJAKnL4UEDMN/FMA0GCSqGSIb3DQEBBQUAMGoxCzAJBgNV
|
||||||
|
BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgw
|
||||||
|
FgYDVQQKEw9BbWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3Mu
|
||||||
|
Y29tMB4XDTE0MDYwNTE0MjgwMloXDTI0MDYwNTE0MjgwMlowajELMAkGA1UEBhMC
|
||||||
|
VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1NlYXR0bGUxGDAWBgNV
|
||||||
|
BAoTD0FtYXpvbi5jb20gSW5jLjEaMBgGA1UEAxMRZWMyLmFtYXpvbmF3cy5jb20w
|
||||||
|
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAIe9GN//SRK2knbjySG0ho3yqQM3
|
||||||
|
e2TDhWO8D2e8+XZqck754gFSo99AbT2RmXClambI7xsYHZFapbELC4H91ycihvrD
|
||||||
|
jbST1ZjkLQgga0NE1q43eS68ZeTDccScXQSNivSlzJZS8HJZjgqzBlXjZftjtdJL
|
||||||
|
XeE4hwvo0sD4f3j9AgMBAAGjgc8wgcwwHQYDVR0OBBYEFCXWzAgVyrbwnFncFFIs
|
||||||
|
77VBdlE4MIGcBgNVHSMEgZQwgZGAFCXWzAgVyrbwnFncFFIs77VBdlE4oW6kbDBq
|
||||||
|
MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2Vh
|
||||||
|
dHRsZTEYMBYGA1UEChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1h
|
||||||
|
em9uYXdzLmNvbYIJAKnL4UEDMN/FMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF
|
||||||
|
BQADgYEAFYcz1OgEhQBXIwIdsgCOS8vEtiJYF+j9uO6jz7VOmJqO+pRlAbRlvY8T
|
||||||
|
C1haGgSI/A1uZUKs/Zfnph0oEI0/hu1IIJ/SKBDtN5lvmZ/IzbOPIJWirlsllQIQ
|
||||||
|
7zvWbGd9c9+Rm3p04oTvhup99la7kZqevJK0QRdD/6NpCKsqP/0=
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
HongKong: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIICSzCCAbQCCQDtQvkVxRvK9TANBgkqhkiG9w0BAQsFADBqMQswCQYDVQQGEwJV
|
||||||
|
UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2VhdHRsZTEYMBYGA1UE
|
||||||
|
ChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1hem9uYXdzLmNvbTAe
|
||||||
|
Fw0xOTAyMDMwMzAwMDZaFw0yOTAyMDIwMzAwMDZaMGoxCzAJBgNVBAYTAlVTMRMw
|
||||||
|
EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgwFgYDVQQKEw9B
|
||||||
|
bWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3MuY29tMIGfMA0G
|
||||||
|
CSqGSIb3DQEBAQUAA4GNADCBiQKBgQC1kkHXYTfc7gY5Q55JJhjTieHAgacaQkiR
|
||||||
|
Pity9QPDE3b+NXDh4UdP1xdIw73JcIIG3sG9RhWiXVCHh6KkuCTqJfPUknIKk8vs
|
||||||
|
M3RXflUpBe8Pf+P92pxqPMCz1Fr2NehS3JhhpkCZVGxxwLC5gaG0Lr4rFORubjYY
|
||||||
|
Rh84dK98VwIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAA6xV9f0HMqXjPHuGILDyaNN
|
||||||
|
dKcvplNFwDTydVg32MNubAGnecoEBtUPtxBsLoVYXCOb+b5/ZMDubPF9tU/vSXuo
|
||||||
|
TpYM5Bq57gJzDRaBOntQbX9bgHiUxw6XZWaTS/6xjRJDT5p3S1E0mPI3lP/eJv4o
|
||||||
|
Ezk5zb3eIf10/sqt4756
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
Bahrain: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDPDCCAqWgAwIBAgIJAMl6uIV/zqJFMA0GCSqGSIb3DQEBCwUAMHIxCzAJBgNV
|
||||||
|
BAYTAlVTMRMwEQYDVQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0dGxlMSAw
|
||||||
|
HgYDVQQKDBdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzEaMBgGA1UEAwwRZWMyLmFt
|
||||||
|
YXpvbmF3cy5jb20wIBcNMTkwNDI2MTQzMjQ3WhgPMjE5ODA5MjkxNDMyNDdaMHIx
|
||||||
|
CzAJBgNVBAYTAlVTMRMwEQYDVQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0
|
||||||
|
dGxlMSAwHgYDVQQKDBdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzEaMBgGA1UEAwwR
|
||||||
|
ZWMyLmFtYXpvbmF3cy5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALVN
|
||||||
|
CDTZEnIeoX1SEYqq6k1BV0ZlpY5y3KnoOreCAE589TwS4MX5+8Fzd6AmACmugeBP
|
||||||
|
Qk7Hm6b2+g/d4tWycyxLaQlcq81DB1GmXehRkZRgGeRge1ePWd1TUA0I8P/QBT7S
|
||||||
|
gUePm/kANSFU+P7s7u1NNl+vynyi0wUUrw7/wIZTAgMBAAGjgdcwgdQwHQYDVR0O
|
||||||
|
BBYEFILtMd+T4YgH1cgc+hVsVOV+480FMIGkBgNVHSMEgZwwgZmAFILtMd+T4YgH
|
||||||
|
1cgc+hVsVOV+480FoXakdDByMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGlu
|
||||||
|
Z3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEgMB4GA1UECgwXQW1hem9uIFdlYiBTZXJ2
|
||||||
|
aWNlcyBMTEMxGjAYBgNVBAMMEWVjMi5hbWF6b25hd3MuY29tggkAyXq4hX/OokUw
|
||||||
|
DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOBgQBhkNTBIFgWFd+ZhC/LhRUY
|
||||||
|
4OjEiykmbEp6hlzQ79T0Tfbn5A4NYDI2icBP0+hmf6qSnIhwJF6typyd1yPK5Fqt
|
||||||
|
NTpxxcXmUKquX+pHmIkK1LKDO8rNE84jqxrxRsfDi6by82fjVYf2pgjJW8R1FAw+
|
||||||
|
mL5WQRFexbfB5aXhcMo0AA==
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
CapeTown: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIICNjCCAZ+gAwIBAgIJAKumfZiRrNvHMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV
|
||||||
|
BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0
|
||||||
|
dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAgFw0xOTExMjcw
|
||||||
|
NzE0MDVaGA8yMTk5MDUwMjA3MTQwNVowXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgT
|
||||||
|
EFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0Ft
|
||||||
|
YXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
|
||||||
|
gQDFd571nUzVtke3rPyRkYfvs3jh0C0EMzzG72boyUNjnfw1+m0TeFraTLKb9T6F
|
||||||
|
7TuB/ZEN+vmlYqr2+5Va8U8qLbPF0bRH+FdaKjhgWZdYXxGzQzU3ioy5W5ZM1VyB
|
||||||
|
7iUsxEAlxsybC3ziPYaHI42UiTkQNahmoroNeqVyHNnBpQIDAQABMA0GCSqGSIb3
|
||||||
|
DQEBCwUAA4GBAAJLylWyElEgOpW4B1XPyRVD4pAds8Guw2+krgqkY0HxLCdjosuH
|
||||||
|
RytGDGN+q75aAoXzW5a7SGpxLxk6Hfv0xp3RjDHsoeP0i1d8MD3hAC5ezxS4oukK
|
||||||
|
s5gbPOnokhKTMPXbTdRn5ZifCbWlx+bYN/mTYKvxho7b5SVg2o1La9aK
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
Milan: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIICNjCCAZ+gAwIBAgIJAOZ3GEIaDcugMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV
|
||||||
|
BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0
|
||||||
|
dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAgFw0xOTEwMjQx
|
||||||
|
NTE5MDlaGA8yMTk5MDMyOTE1MTkwOVowXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgT
|
||||||
|
EFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0Ft
|
||||||
|
YXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
|
||||||
|
gQCjiPgW3vsXRj4JoA16WQDyoPc/eh3QBARaApJEc4nPIGoUolpAXcjFhWplo2O+
|
||||||
|
ivgfCsc4AU9OpYdAPha3spLey/bhHPRi1JZHRNqScKP0hzsCNmKhfnZTIEQCFvsp
|
||||||
|
DRp4zr91/WS06/flJFBYJ6JHhp0KwM81XQG59lV6kkoW7QIDAQABMA0GCSqGSIb3
|
||||||
|
DQEBCwUAA4GBAGLLrY3P+HH6C57dYgtJkuGZGT2+rMkk2n81/abzTJvsqRqGRrWv
|
||||||
|
XRKRXlKdM/dfiuYGokDGxiC0Mg6TYy6wvsR2qRhtXW1OtZkiHWcQCnOttz+8vpew
|
||||||
|
wx8JGMvowtuKB1iMsbwyRqZkFYLcvH+Opfb/Aayi20/ChQLdI6M2R5VU
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
China: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIICSzCCAbQCCQCQu97teKRD4zANBgkqhkiG9w0BAQUFADBqMQswCQYDVQQGEwJV
|
||||||
|
UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2VhdHRsZTEYMBYGA1UE
|
||||||
|
ChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1hem9uYXdzLmNvbTAe
|
||||||
|
Fw0xMzA4MjExMzIyNDNaFw0yMzA4MjExMzIyNDNaMGoxCzAJBgNVBAYTAlVTMRMw
|
||||||
|
EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgwFgYDVQQKEw9B
|
||||||
|
bWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3MuY29tMIGfMA0G
|
||||||
|
CSqGSIb3DQEBAQUAA4GNADCBiQKBgQC6GFQ2WoBl1xZYH85INUMaTc4D30QXM6f+
|
||||||
|
YmWZyJD9fC7Z0UlaZIKoQATqCO58KNCre+jECELYIX56Uq0lb8LRLP8tijrQ9Sp3
|
||||||
|
qJcXiH66kH0eQ44a5YdewcFOy+CSAYDUIaB6XhTQJ2r7bd4A2vw3ybbxTOWONKdO
|
||||||
|
WtgIe3M3iwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAHzQC5XZVeuD9GTJTsbO5AyH
|
||||||
|
ZQvki/jfARNrD9dgBRYZzLC/NOkWG6M9wlrmks9RtdNxc53nLxKq4I2Dd73gI0yQ
|
||||||
|
wYu9YYwmM/LMgmPlI33Rg2Ohwq4DVgT3hO170PL6Fsgiq3dMvctSImJvjWktBQaT
|
||||||
|
bcAgaZLHGIpXPrWSA2d+
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
GovCloud: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDCzCCAnSgAwIBAgIJAIe9Hnq82O7UMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV
|
||||||
|
BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0
|
||||||
|
dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAeFw0yMTA3MTQx
|
||||||
|
NDI3NTdaFw0yNDA3MTMxNDI3NTdaMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBX
|
||||||
|
YXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6
|
||||||
|
b24gV2ViIFNlcnZpY2VzIExMQzCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA
|
||||||
|
qaIcGFFTx/SO1W5G91jHvyQdGP25n1Y91aXCuOOWAUTvSvNGpXrI4AXNrQF+CmIO
|
||||||
|
C4beBASnHCx082jYudWBBl9Wiza0psYc9flrczSzVLMmN8w/c78F/95NfiQdnUQP
|
||||||
|
pvgqcMeJo82cgHkLR7XoFWgMrZJqrcUK0gnsQcb6kakCAwEAAaOB1DCB0TALBgNV
|
||||||
|
HQ8EBAMCB4AwHQYDVR0OBBYEFNWV53gWJz72F5B1ZVY4O/dfFYBPMIGOBgNVHSME
|
||||||
|
gYYwgYOAFNWV53gWJz72F5B1ZVY4O/dfFYBPoWCkXjBcMQswCQYDVQQGEwJVUzEZ
|
||||||
|
MBcGA1UECBMQV2FzaGluZ3RvbiBTdGF0ZTEQMA4GA1UEBxMHU2VhdHRsZTEgMB4G
|
||||||
|
A1UEChMXQW1hem9uIFdlYiBTZXJ2aWNlcyBMTEOCCQCHvR56vNju1DASBgNVHRMB
|
||||||
|
Af8ECDAGAQH/AgEAMA0GCSqGSIb3DQEBCwUAA4GBACrKjWj460GUPZCGm3/z0dIz
|
||||||
|
M2BPuH769wcOsqfFZcMKEysSFK91tVtUb1soFwH4/Lb/T0PqNrvtEwD1Nva5k0h2
|
||||||
|
xZhNNRmDuhOhW1K9wCcnHGRBwY5t4lYL6hNV6hcrqYwGMjTjcAjBG2yMgznSNFle
|
||||||
|
Rwi/S3BFXISixNx9cILu
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package awsidentity_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/coder/coder/coderd/awsidentity"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
signature = `M7rX9w1s5zK1V7hK0dsE4hTDXHHaaDuKQ9iIz/W8ZNaA2lJ/usz5YuX+ORt3luJwswl/+B7cYOkJ
|
||||||
|
bXRMx/pEQ6vT+niLGZDC9ZZ1h9Ox4h4e4m4IisQSCUrVIzyLj+MB27/Wyy0NhXcpoZVjNEmioxF2
|
||||||
|
HNpOR4aCwUxxOm81y98=`
|
||||||
|
document = `{
|
||||||
|
"accountId" : "628783029487",
|
||||||
|
"architecture" : "x86_64",
|
||||||
|
"availabilityZone" : "us-east-1b",
|
||||||
|
"billingProducts" : null,
|
||||||
|
"devpayProductCodes" : null,
|
||||||
|
"marketplaceProductCodes" : null,
|
||||||
|
"imageId" : "ami-0c02fb55956c7d316",
|
||||||
|
"instanceId" : "i-076e9b91f7c420782",
|
||||||
|
"instanceType" : "t2.micro",
|
||||||
|
"kernelId" : null,
|
||||||
|
"pendingTime" : "2022-03-25T20:07:16Z",
|
||||||
|
"privateIp" : "172.31.84.238",
|
||||||
|
"ramdiskId" : null,
|
||||||
|
"region" : "us-east-1",
|
||||||
|
"version" : "2017-09-30"
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("FailEmpty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := awsidentity.Validate("", "", nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
t.Run("FailBad", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, err := awsidentity.Validate(signature, "{}", nil)
|
||||||
|
require.ErrorIs(t, err, rsa.ErrVerification)
|
||||||
|
})
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
identity, err := awsidentity.Validate(signature, document, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, awsidentity.Other, identity.Region)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"google.golang.org/api/idtoken"
|
"google.golang.org/api/idtoken"
|
||||||
|
|
||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
|
"github.com/coder/coder/coderd/awsidentity"
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/httpapi"
|
"github.com/coder/coder/coderd/httpapi"
|
||||||
"github.com/coder/coder/coderd/httpmw"
|
"github.com/coder/coder/coderd/httpmw"
|
||||||
@@ -24,6 +25,7 @@ type Options struct {
|
|||||||
Database database.Store
|
Database database.Store
|
||||||
Pubsub database.Pubsub
|
Pubsub database.Pubsub
|
||||||
|
|
||||||
|
AWSCertificates awsidentity.Certificates
|
||||||
GoogleTokenValidator *idtoken.Validator
|
GoogleTokenValidator *idtoken.Validator
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +137,7 @@ func New(options *Options) (http.Handler, func()) {
|
|||||||
})
|
})
|
||||||
r.Route("/workspaceresources", func(r chi.Router) {
|
r.Route("/workspaceresources", func(r chi.Router) {
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity)
|
||||||
r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity)
|
r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity)
|
||||||
})
|
})
|
||||||
r.Route("/agent", func(r chi.Router) {
|
r.Route("/agent", func(r chi.Router) {
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package coderdtest
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"math/big"
|
"math/big"
|
||||||
@@ -31,6 +35,7 @@ import (
|
|||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
"cdr.dev/slog/sloggers/slogtest"
|
"cdr.dev/slog/sloggers/slogtest"
|
||||||
"github.com/coder/coder/coderd"
|
"github.com/coder/coder/coderd"
|
||||||
|
"github.com/coder/coder/coderd/awsidentity"
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/database/databasefake"
|
"github.com/coder/coder/coderd/database/databasefake"
|
||||||
"github.com/coder/coder/coderd/database/postgres"
|
"github.com/coder/coder/coderd/database/postgres"
|
||||||
@@ -43,7 +48,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
GoogleTokenValidator *idtoken.Validator
|
AWSInstanceIdentity awsidentity.Certificates
|
||||||
|
GoogleInstanceIdentity *idtoken.Validator
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs an in-memory coderd instance and returns
|
// New constructs an in-memory coderd instance and returns
|
||||||
@@ -52,11 +58,11 @@ func New(t *testing.T, options *Options) *codersdk.Client {
|
|||||||
if options == nil {
|
if options == nil {
|
||||||
options = &Options{}
|
options = &Options{}
|
||||||
}
|
}
|
||||||
if options.GoogleTokenValidator == nil {
|
if options.GoogleInstanceIdentity == nil {
|
||||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancelFunc)
|
t.Cleanup(cancelFunc)
|
||||||
var err error
|
var err error
|
||||||
options.GoogleTokenValidator, err = idtoken.NewValidator(ctx, option.WithoutAuthentication())
|
options.GoogleInstanceIdentity, err = idtoken.NewValidator(ctx, option.WithoutAuthentication())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +107,8 @@ func New(t *testing.T, options *Options) *codersdk.Client {
|
|||||||
Database: db,
|
Database: db,
|
||||||
Pubsub: pubsub,
|
Pubsub: pubsub,
|
||||||
|
|
||||||
GoogleTokenValidator: options.GoogleTokenValidator,
|
AWSCertificates: options.AWSInstanceIdentity,
|
||||||
|
GoogleTokenValidator: options.GoogleInstanceIdentity,
|
||||||
})
|
})
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
srv.Close()
|
srv.Close()
|
||||||
@@ -334,6 +341,66 @@ func NewGoogleInstanceIdentity(t *testing.T, instanceID string, expired bool) (*
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewAWSInstanceIdentity returns a metadata client and ID token validator for faking
|
||||||
|
// instance authentication for AWS.
|
||||||
|
func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certificates, *http.Client) {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
document := []byte(`{"instanceId":"` + instanceID + `"}`)
|
||||||
|
hashedDocument := sha256.Sum256(document)
|
||||||
|
|
||||||
|
signatureRaw, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashedDocument[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
signature := make([]byte, base64.StdEncoding.EncodedLen(len(signatureRaw)))
|
||||||
|
base64.StdEncoding.Encode(signature, signatureRaw)
|
||||||
|
|
||||||
|
certificate, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(2022),
|
||||||
|
}, &x509.Certificate{}, &privateKey.PublicKey, privateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
certificatePEM := bytes.Buffer{}
|
||||||
|
err = pem.Encode(&certificatePEM, &pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: certificate,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return awsidentity.Certificates{
|
||||||
|
awsidentity.Other: certificatePEM.String(),
|
||||||
|
}, &http.Client{
|
||||||
|
Transport: roundTripper(func(r *http.Request) (*http.Response, error) {
|
||||||
|
// Only handle metadata server requests.
|
||||||
|
if r.URL.Host != "169.254.169.254" {
|
||||||
|
return http.DefaultTransport.RoundTrip(r)
|
||||||
|
}
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/latest/api/token":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: ioutil.NopCloser(bytes.NewReader([]byte("faketoken"))),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
case "/latest/dynamic/instance-identity/signature":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: ioutil.NopCloser(bytes.NewReader(signature)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
case "/latest/dynamic/instance-identity/document":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Body: ioutil.NopCloser(bytes.NewReader(document)),
|
||||||
|
Header: make(http.Header),
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
panic("unhandled route: " + r.URL.Path)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func randomUsername() string {
|
func randomUsername() string {
|
||||||
return strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-")
|
return strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ func TestNew(t *testing.T) {
|
|||||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||||
_, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false)
|
_, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false)
|
||||||
|
_, _ = coderdtest.NewAWSInstanceIdentity(t, "an-instance")
|
||||||
closer.Close()
|
closer.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
|
|
||||||
|
"github.com/coder/coder/coderd/awsidentity"
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/httpapi"
|
"github.com/coder/coder/coderd/httpapi"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
@@ -16,6 +17,24 @@ import (
|
|||||||
"github.com/mitchellh/mapstructure"
|
"github.com/mitchellh/mapstructure"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AWS supports instance identity verification:
|
||||||
|
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
|
||||||
|
// Using this, we can exchange a signed instance payload for an agent token.
|
||||||
|
func (api *api) postWorkspaceAuthAWSInstanceIdentity(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var req codersdk.AWSInstanceIdentityToken
|
||||||
|
if !httpapi.Read(rw, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
identity, err := awsidentity.Validate(req.Signature, req.Document, api.AWSCertificates)
|
||||||
|
if err != nil {
|
||||||
|
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
|
||||||
|
Message: fmt.Sprintf("validate: %s", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.handleAuthInstanceID(rw, r, identity.InstanceID)
|
||||||
|
}
|
||||||
|
|
||||||
// Google Compute Engine supports instance identity verification:
|
// Google Compute Engine supports instance identity verification:
|
||||||
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
|
// https://cloud.google.com/compute/docs/instances/verifying-instance-identity
|
||||||
// Using this, we can exchange a signed instance payload for an agent token.
|
// Using this, we can exchange a signed instance payload for an agent token.
|
||||||
@@ -47,10 +66,14 @@ func (api *api) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter,
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
agent, err := api.Database.GetWorkspaceAgentByInstanceID(r.Context(), claims.Google.ComputeEngine.InstanceID)
|
api.handleAuthInstanceID(rw, r, claims.Google.ComputeEngine.InstanceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *api) handleAuthInstanceID(rw http.ResponseWriter, r *http.Request, instanceID string) {
|
||||||
|
agent, err := api.Database.GetWorkspaceAgentByInstanceID(r.Context(), instanceID)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
|
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
|
||||||
Message: fmt.Sprintf("instance with id %q not found", claims.Google.ComputeEngine.InstanceID),
|
Message: fmt.Sprintf("instance with id %q not found", instanceID),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -107,7 +130,7 @@ func (api *api) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter,
|
|||||||
}
|
}
|
||||||
if latestHistory.ID.String() != resourceHistory.ID.String() {
|
if latestHistory.ID.String() != resourceHistory.ID.String() {
|
||||||
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
|
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
|
||||||
Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", claims.Google.ComputeEngine.InstanceID),
|
Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", instanceID),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,46 @@ import (
|
|||||||
"github.com/coder/coder/provisionersdk/proto"
|
"github.com/coder/coder/provisionersdk/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
instanceID := "instanceidentifier"
|
||||||
|
certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID)
|
||||||
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
|
AWSInstanceIdentity: certificates,
|
||||||
|
})
|
||||||
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
coderdtest.NewProvisionerDaemon(t, client)
|
||||||
|
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
|
Parse: echo.ParseComplete,
|
||||||
|
Provision: []*proto.Provision_Response{{
|
||||||
|
Type: &proto.Provision_Response_Complete{
|
||||||
|
Complete: &proto.Provision_Complete{
|
||||||
|
Resources: []*proto.Resource{{
|
||||||
|
Name: "somename",
|
||||||
|
Type: "someinstance",
|
||||||
|
Agent: &proto.Agent{
|
||||||
|
Auth: &proto.Agent_InstanceId{
|
||||||
|
InstanceId: instanceID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID)
|
||||||
|
coderdtest.AwaitProjectVersionJob(t, client, version.ID)
|
||||||
|
workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||||
|
|
||||||
|
client.HTTPClient = metadataClient
|
||||||
|
_, err := client.AuthWorkspaceAWSInstanceIdentity(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
t.Run("Expired", func(t *testing.T) {
|
t.Run("Expired", func(t *testing.T) {
|
||||||
@@ -20,7 +60,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
|||||||
instanceID := "instanceidentifier"
|
instanceID := "instanceidentifier"
|
||||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true)
|
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true)
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
GoogleTokenValidator: validator,
|
GoogleInstanceIdentity: validator,
|
||||||
})
|
})
|
||||||
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata)
|
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata)
|
||||||
var apiErr *codersdk.Error
|
var apiErr *codersdk.Error
|
||||||
@@ -33,7 +73,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
|||||||
instanceID := "instanceidentifier"
|
instanceID := "instanceidentifier"
|
||||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
GoogleTokenValidator: validator,
|
GoogleInstanceIdentity: validator,
|
||||||
})
|
})
|
||||||
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata)
|
_, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata)
|
||||||
var apiErr *codersdk.Error
|
var apiErr *codersdk.Error
|
||||||
@@ -46,27 +86,12 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) {
|
|||||||
instanceID := "instanceidentifier"
|
instanceID := "instanceidentifier"
|
||||||
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false)
|
||||||
client := coderdtest.New(t, &coderdtest.Options{
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
GoogleTokenValidator: validator,
|
GoogleInstanceIdentity: validator,
|
||||||
})
|
})
|
||||||
user := coderdtest.CreateFirstUser(t, client)
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
coderdtest.NewProvisionerDaemon(t, client)
|
coderdtest.NewProvisionerDaemon(t, client)
|
||||||
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
|
version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
Parse: echo.ParseComplete,
|
Parse: echo.ParseComplete,
|
||||||
ProvisionDryRun: []*proto.Provision_Response{{
|
|
||||||
Type: &proto.Provision_Response_Complete{
|
|
||||||
Complete: &proto.Provision_Complete{
|
|
||||||
Resources: []*proto.Resource{{
|
|
||||||
Name: "somename",
|
|
||||||
Type: "someinstance",
|
|
||||||
Agent: &proto.Agent{
|
|
||||||
Auth: &proto.Agent_InstanceId{
|
|
||||||
InstanceId: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
Provision: []*proto.Provision_Response{{
|
Provision: []*proto.Provision_Response{{
|
||||||
Type: &proto.Provision_Response_Complete{
|
Type: &proto.Provision_Response_Complete{
|
||||||
Complete: &proto.Provision_Complete{
|
Complete: &proto.Provision_Complete{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"cloud.google.com/go/compute/metadata"
|
"cloud.google.com/go/compute/metadata"
|
||||||
@@ -14,6 +15,11 @@ type GoogleInstanceIdentityToken struct {
|
|||||||
JSONWebToken string `json:"json_web_token" validate:"required"`
|
JSONWebToken string `json:"json_web_token" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AWSInstanceIdentityToken struct {
|
||||||
|
Signature string `json:"signature" validate:"required"`
|
||||||
|
Document string `json:"document" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
|
// WorkspaceAgentAuthenticateResponse is returned when an instance ID
|
||||||
// has been exchanged for a session token.
|
// has been exchanged for a session token.
|
||||||
type WorkspaceAgentAuthenticateResponse struct {
|
type WorkspaceAgentAuthenticateResponse struct {
|
||||||
@@ -50,3 +56,68 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic
|
|||||||
var resp WorkspaceAgentAuthenticateResponse
|
var resp WorkspaceAgentAuthenticateResponse
|
||||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthWorkspaceAWSInstanceIdentity uses the Amazon Metadata API to
|
||||||
|
// fetch a signed payload, and exchange it for a session token for a workspace agent.
|
||||||
|
//
|
||||||
|
// The requesting instance must be registered as a resource in the latest history for a workspace.
|
||||||
|
func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context) (WorkspaceAgentAuthenticateResponse, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
||||||
|
}
|
||||||
|
req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600")
|
||||||
|
res, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
token, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/signature", nil)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
||||||
|
}
|
||||||
|
req.Header.Set("X-aws-ec2-metadata-token", string(token))
|
||||||
|
res, err = c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
signature, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/document", nil)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, nil
|
||||||
|
}
|
||||||
|
req.Header.Set("X-aws-ec2-metadata-token", string(token))
|
||||||
|
res, err = c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
document, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/aws-instance-identity", AWSInstanceIdentityToken{
|
||||||
|
Signature: string(signature),
|
||||||
|
Document: string(document),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res)
|
||||||
|
}
|
||||||
|
var resp WorkspaceAgentAuthenticateResponse
|
||||||
|
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,7 +176,11 @@ func TestProvisionerd(t *testing.T) {
|
|||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) {
|
updateJob: func(ctx context.Context, update *proto.UpdateJobRequest) (*proto.UpdateJobResponse, error) {
|
||||||
close(completeChan)
|
select {
|
||||||
|
case <-completeChan:
|
||||||
|
default:
|
||||||
|
close(completeChan)
|
||||||
|
}
|
||||||
return &proto.UpdateJobResponse{}, nil
|
return &proto.UpdateJobResponse{}, nil
|
||||||
},
|
},
|
||||||
failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) {
|
failJob: func(ctx context.Context, job *proto.FailedJob) (*proto.Empty, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user