chore: move app proxying code to workspaceapps pkg (#6998)

* chore: move app proxying code to workspaceapps pkg

Moves path-app, subdomain-app and reconnecting PTY proxying to the new
workspaceapps.WorkspaceAppServer struct. This is in preparation for
external workspace proxies.

Updates app logout flow to avoid redirecting to coder-logout.${app_host}
on logout. Instead, all subdomain app tokens owned by the logging-out
user will be deleted every time you logout for simplicity sake.

Tests will remain in their original package, pending being moved to an
apptest package (or similar).

Co-authored-by: Steven Masley <stevenmasley@coder.com>
This commit is contained in:
Dean Sheather
2023-04-06 04:41:55 +10:00
committed by GitHub
parent 0069831e8d
commit eb66cc9f35
28 changed files with 1236 additions and 1334 deletions

View File

@ -0,0 +1,422 @@
package workspaceapps_test
import (
"fmt"
"testing"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/cryptorand"
)
func Test_TokenMatchesRequest(t *testing.T) {
t.Parallel()
cases := []struct {
name string
req workspaceapps.Request
token workspaceapps.SignedToken
want bool
}{
{
name: "OK",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
},
want: true,
},
{
name: "DifferentAccessMethod",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
},
},
want: false,
},
{
name: "DifferentBasePath",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
},
},
want: false,
},
{
name: "DifferentUsernameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "bar",
},
},
want: false,
},
{
name: "DifferentWorkspaceNameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "baz",
},
},
want: false,
},
{
name: "DifferentAgentNameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "qux",
},
},
want: false,
},
{
name: "DifferentAppSlugOrPort",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "quux",
},
},
want: false,
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, c.want, c.token.MatchesRequest(c.req))
})
}
}
func Test_GenerateToken(t *testing.T) {
t.Parallel()
t.Run("SetExpiry", func(t *testing.T) {
t.Parallel()
tokenStr, err := coderdtest.AppSecurityKey.SignToken(workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Time{},
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
})
require.NoError(t, err)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
require.NoError(t, err)
require.WithinDuration(t, time.Now().Add(time.Minute), token.Expiry, 15*time.Second)
})
future := time.Now().Add(time.Hour)
cases := []struct {
name string
token workspaceapps.SignedToken
parseErrContains string
}{
{
name: "OK1",
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: future,
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
},
},
{
name: "OK2",
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: "oof",
WorkspaceNameOrID: "rab",
AgentNameOrID: "zab",
AppSlugOrPort: "xuq",
},
Expiry: future,
UserID: uuid.MustParse("6fa684a3-11aa-49fd-8512-ab527bd9b900"),
WorkspaceID: uuid.MustParse("b2d816cc-505c-441d-afdf-dae01781bc0b"),
AgentID: uuid.MustParse("6c4396e1-af88-4a8a-91a3-13ea54fc29fb"),
AppURL: "http://localhost:9090",
},
},
{
name: "Expired",
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(-time.Hour),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
},
parseErrContains: "token expired",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
str, err := coderdtest.AppSecurityKey.SignToken(c.token)
require.NoError(t, err)
// Tokens aren't deterministic as they have a random nonce, so we
// can't compare them directly.
token, err := coderdtest.AppSecurityKey.VerifySignedToken(str)
if c.parseErrContains != "" {
require.Error(t, err)
require.ErrorContains(t, err, c.parseErrContains)
} else {
require.NoError(t, err)
// normalize the expiry
require.WithinDuration(t, c.token.Expiry, token.Expiry, 10*time.Second)
c.token.Expiry = token.Expiry
require.Equal(t, c.token, token)
}
})
}
}
// The ParseToken fn is tested quite thoroughly in the GenerateToken test as
// well.
func Test_ParseToken(t *testing.T) {
t.Parallel()
t.Run("InvalidJWS", func(t *testing.T) {
t.Parallel()
token, err := coderdtest.AppSecurityKey.VerifySignedToken("invalid")
require.Error(t, err)
require.ErrorContains(t, err, "parse JWS")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
t.Run("VerifySignature", func(t *testing.T) {
t.Parallel()
// Create a valid token using a different key.
var otherKey workspaceapps.SecurityKey
copy(otherKey[:], coderdtest.AppSecurityKey[:])
for i := range otherKey {
otherKey[i] ^= 0xff
}
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
tokenStr, err := otherKey.SignToken(workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(time.Hour),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
AppURL: "http://127.0.0.1:8080",
})
require.NoError(t, err)
// Verify the token is invalid.
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
require.Error(t, err)
require.ErrorContains(t, err, "verify JWS")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
t.Run("InvalidBody", func(t *testing.T) {
t.Parallel()
// Create a signature for an invalid body.
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSecurityKey[:64]}, nil)
require.NoError(t, err)
signedObject, err := signer.Sign([]byte("hi"))
require.NoError(t, err)
serialized, err := signedObject.CompactSerialize()
require.NoError(t, err)
token, err := coderdtest.AppSecurityKey.VerifySignedToken(serialized)
require.Error(t, err)
require.ErrorContains(t, err, "unmarshal payload")
require.Equal(t, workspaceapps.SignedToken{}, token)
})
}
func TestAPIKeyEncryption(t *testing.T) {
t.Parallel()
genAPIKey := func(t *testing.T) string {
id, _ := cryptorand.String(10)
secret, _ := cryptorand.String(22)
return fmt.Sprintf("%s-%s", id, secret)
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
key := genAPIKey(t)
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
})
require.NoError(t, err)
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.NoError(t, err)
require.Equal(t, key, decryptedKey)
})
t.Run("Verifies", func(t *testing.T) {
t.Parallel()
t.Run("Expiry", func(t *testing.T) {
t.Parallel()
key := genAPIKey(t)
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
ExpiresAt: database.Now().Add(-1 * time.Hour),
})
require.NoError(t, err)
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.Error(t, err)
require.ErrorContains(t, err, "expired")
require.Empty(t, decryptedKey)
})
t.Run("EncryptionKey", func(t *testing.T) {
t.Parallel()
// Create a valid token using a different key.
var otherKey workspaceapps.SecurityKey
copy(otherKey[:], coderdtest.AppSecurityKey[:])
for i := range otherKey {
otherKey[i] ^= 0xff
}
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
// Encrypt with the other key.
key := genAPIKey(t)
encrypted, err := otherKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
APIKey: key,
})
require.NoError(t, err)
// Decrypt with the original key.
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
require.Error(t, err)
require.ErrorContains(t, err, "decrypt API key")
require.Empty(t, decryptedKey)
})
})
}