mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: enable key rotation (#15066)
This PR contains the remaining logic necessary to hook up key rotation to the product.
This commit is contained in:
@ -3,6 +3,7 @@ package apptest
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -408,6 +409,67 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
assertWorkspaceLastUsedAtNotUpdated(t, appDetails)
|
||||
})
|
||||
|
||||
t.Run("BadJWT", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appDetails := setupProxyTest(t, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxyTestAppBody, string(body))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
appTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
|
||||
require.NotNil(t, appTokenCookie, "no signed app token cookie in response")
|
||||
require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie")
|
||||
|
||||
object, err := jose.ParseSigned(appTokenCookie.Value)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, object.Signatures, 1)
|
||||
|
||||
// Parse the payload.
|
||||
var tok workspaceapps.SignedToken
|
||||
//nolint:gosec
|
||||
err = json.Unmarshal(object.UnsafePayloadWithoutVerification(), &tok)
|
||||
require.NoError(t, err)
|
||||
|
||||
appTokenClient := appDetails.AppClient(t)
|
||||
apiKey := appTokenClient.SessionToken()
|
||||
appTokenClient.SetSessionToken("")
|
||||
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
// Sign the token with an old-style key.
|
||||
appTokenCookie.Value = generateBadJWT(t, tok)
|
||||
appTokenClient.HTTPClient.Jar.SetCookies(u,
|
||||
[]*http.Cookie{
|
||||
appTokenCookie,
|
||||
{
|
||||
Name: codersdk.PathAppSessionTokenCookie,
|
||||
Value: apiKey,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxyTestAppBody, string(body))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
||||
|
||||
// Since the old token is invalid, the signed app token cookie should have a new value.
|
||||
newTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
|
||||
require.NotEqual(t, appTokenCookie.Value, newTokenCookie.Value)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("WorkspaceApplicationAuth", func(t *testing.T) {
|
||||
@ -463,7 +525,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
|
||||
appClient.SetSessionToken("")
|
||||
|
||||
// Try to load the application without authentication.
|
||||
u := c.appURL
|
||||
u := *c.appURL
|
||||
u.Path = path.Join(u.Path, "/test")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
@ -500,7 +562,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
|
||||
|
||||
// Copy the query parameters and then check equality.
|
||||
u.RawQuery = gotLocation.RawQuery
|
||||
require.Equal(t, u, gotLocation)
|
||||
require.Equal(t, u, *gotLocation)
|
||||
|
||||
// Verify the API key is set.
|
||||
encryptedAPIKey := gotLocation.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
|
||||
@ -580,6 +642,38 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BadJWE", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
currentKeyStr := appDetails.SDKClient.SessionToken()
|
||||
appClient := appDetails.AppClient(t)
|
||||
appClient.SetSessionToken("")
|
||||
u := *c.appURL
|
||||
u.Path = path.Join(u.Path, "/test")
|
||||
badToken := generateBadJWE(t, workspaceapps.EncryptedAPIKeyPayload{
|
||||
APIKey: currentKeyStr,
|
||||
})
|
||||
|
||||
u.RawQuery = (url.Values{
|
||||
workspaceapps.SubdomainProxyAPIKeyParam: {badToken},
|
||||
}).Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp *http.Response
|
||||
resp, err = doWithRetries(t, appClient, req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(body), "Could not decrypt API key. Please remove the query parameter and try again.")
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1077,6 +1171,68 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) {
|
||||
assertWorkspaceLastUsedAtNotUpdated(t, appDetails)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("BadJWT", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appDetails := setupProxyTest(t, nil)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxyTestAppBody, string(body))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
appTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
|
||||
require.NotNil(t, appTokenCookie, "no signed token cookie in response")
|
||||
require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie")
|
||||
|
||||
object, err := jose.ParseSigned(appTokenCookie.Value)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, object.Signatures, 1)
|
||||
|
||||
// Parse the payload.
|
||||
var tok workspaceapps.SignedToken
|
||||
//nolint:gosec
|
||||
err = json.Unmarshal(object.UnsafePayloadWithoutVerification(), &tok)
|
||||
require.NoError(t, err)
|
||||
|
||||
appTokenClient := appDetails.AppClient(t)
|
||||
apiKey := appTokenClient.SessionToken()
|
||||
appTokenClient.SetSessionToken("")
|
||||
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
// Sign the token with an old-style key.
|
||||
appTokenCookie.Value = generateBadJWT(t, tok)
|
||||
appTokenClient.HTTPClient.Jar.SetCookies(u,
|
||||
[]*http.Cookie{
|
||||
appTokenCookie,
|
||||
{
|
||||
Name: codersdk.SubdomainAppSessionTokenCookie,
|
||||
Value: apiKey,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// We should still be able to successfully proxy.
|
||||
resp, err = requestWithRetries(ctx, t, appTokenClient, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, proxyTestAppBody, string(body))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assertWorkspaceLastUsedAtUpdated(t, appDetails)
|
||||
|
||||
// Since the old token is invalid, the signed app token cookie should have a new value.
|
||||
newTokenCookie := findCookie(resp.Cookies(), codersdk.SignedAppTokenCookie)
|
||||
require.NotEqual(t, appTokenCookie.Value, newTokenCookie.Value)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("PortSharing", func(t *testing.T) {
|
||||
@ -1789,3 +1945,57 @@ func assertWorkspaceLastUsedAtNotUpdated(t testing.TB, details *Details) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before.LastUsedAt, after.LastUsedAt, "workspace LastUsedAt updated when it should not have been")
|
||||
}
|
||||
|
||||
func generateBadJWE(t *testing.T, claims interface{}) string {
|
||||
t.Helper()
|
||||
var buf [32]byte
|
||||
_, err := rand.Read(buf[:])
|
||||
require.NoError(t, err)
|
||||
encrypt, err := jose.NewEncrypter(
|
||||
jose.A256GCM,
|
||||
jose.Recipient{
|
||||
Algorithm: jose.A256GCMKW,
|
||||
Key: buf[:],
|
||||
}, &jose.EncrypterOptions{
|
||||
Compression: jose.DEFLATE,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
payload, err := json.Marshal(claims)
|
||||
require.NoError(t, err)
|
||||
signed, err := encrypt.Encrypt(payload)
|
||||
require.NoError(t, err)
|
||||
compact, err := signed.CompactSerialize()
|
||||
require.NoError(t, err)
|
||||
return compact
|
||||
}
|
||||
|
||||
// generateBadJWT generates a JWT with a random key. It's intended to emulate the old-style JWT's we generated.
|
||||
func generateBadJWT(t *testing.T, claims interface{}) string {
|
||||
t.Helper()
|
||||
|
||||
var buf [64]byte
|
||||
_, err := rand.Read(buf[:])
|
||||
require.NoError(t, err)
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.HS512,
|
||||
Key: buf[:],
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
payload, err := json.Marshal(claims)
|
||||
require.NoError(t, err)
|
||||
signed, err := signer.Sign(payload)
|
||||
require.NoError(t, err)
|
||||
compact, err := signed.CompactSerialize()
|
||||
require.NoError(t, err)
|
||||
return compact
|
||||
}
|
||||
|
||||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -13,11 +13,15 @@ import (
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -35,12 +39,20 @@ type DBTokenProvider struct {
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
OAuth2Configs *httpmw.OAuth2Configs
|
||||
WorkspaceAgentInactiveTimeout time.Duration
|
||||
SigningKey SecurityKey
|
||||
Keycache cryptokeys.SigningKeycache
|
||||
}
|
||||
|
||||
var _ SignedTokenProvider = &DBTokenProvider{}
|
||||
|
||||
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, signingKey SecurityKey) SignedTokenProvider {
|
||||
func NewDBTokenProvider(log slog.Logger,
|
||||
accessURL *url.URL,
|
||||
authz rbac.Authorizer,
|
||||
db database.Store,
|
||||
cfg *codersdk.DeploymentValues,
|
||||
oauth2Cfgs *httpmw.OAuth2Configs,
|
||||
workspaceAgentInactiveTimeout time.Duration,
|
||||
signer cryptokeys.SigningKeycache,
|
||||
) SignedTokenProvider {
|
||||
if workspaceAgentInactiveTimeout == 0 {
|
||||
workspaceAgentInactiveTimeout = 1 * time.Minute
|
||||
}
|
||||
@ -53,12 +65,12 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
|
||||
DeploymentValues: cfg,
|
||||
OAuth2Configs: oauth2Cfgs,
|
||||
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
|
||||
SigningKey: signingKey,
|
||||
Keycache: signer,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DBTokenProvider) FromRequest(r *http.Request) (*SignedToken, bool) {
|
||||
return FromRequest(r, p.SigningKey)
|
||||
return FromRequest(r, p.Keycache)
|
||||
}
|
||||
|
||||
func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq IssueTokenRequest) (*SignedToken, string, bool) {
|
||||
@ -70,7 +82,7 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
|
||||
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
appReq := issueReq.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
err := appReq.Check()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, "", false
|
||||
@ -210,9 +222,11 @@ func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
token.RegisteredClaims = jwtutils.RegisteredClaims{
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(DefaultTokenExpiry)),
|
||||
}
|
||||
// Sign the token.
|
||||
token.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
tokenStr, err := p.SigningKey.SignToken(token)
|
||||
tokenStr, err := jwtutils.Sign(ctx, p.Keycache, token)
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "generate token")
|
||||
return nil, "", false
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -20,6 +21,7 @@ import (
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@ -94,8 +96,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
_ = closer.Close()
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
t.Cleanup(cancel)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
@ -276,15 +277,17 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
_ = w.Body.Close()
|
||||
|
||||
require.Equal(t, &workspaceapps.SignedToken{
|
||||
RegisteredClaims: jwtutils.RegisteredClaims{
|
||||
Expiry: jwt.NewNumericDate(token.Expiry.Time()),
|
||||
},
|
||||
Request: req,
|
||||
Expiry: token.Expiry, // ignored to avoid flakiness
|
||||
UserID: me.ID,
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: agentID,
|
||||
AppURL: appURL,
|
||||
}, token)
|
||||
require.NotZero(t, token.Expiry)
|
||||
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry, time.Minute)
|
||||
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry.Time(), time.Minute)
|
||||
|
||||
// Check that the token was set in the response and is valid.
|
||||
require.Len(t, w.Cookies(), 1)
|
||||
@ -292,10 +295,11 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Equal(t, codersdk.SignedAppTokenCookie, cookie.Name)
|
||||
require.Equal(t, req.BasePath, cookie.Path)
|
||||
|
||||
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookie.Value)
|
||||
var parsedToken workspaceapps.SignedToken
|
||||
err := jwtutils.Verify(ctx, api.AppSigningKeyCache, cookie.Value, &parsedToken)
|
||||
require.NoError(t, err)
|
||||
// normalize expiry
|
||||
require.WithinDuration(t, token.Expiry, parsedToken.Expiry, 2*time.Second)
|
||||
require.WithinDuration(t, token.Expiry.Time(), parsedToken.Expiry.Time(), 2*time.Second)
|
||||
parsedToken.Expiry = token.Expiry
|
||||
require.Equal(t, token, &parsedToken)
|
||||
|
||||
@ -314,7 +318,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
})
|
||||
require.True(t, ok)
|
||||
// normalize expiry
|
||||
require.WithinDuration(t, token.Expiry, secondToken.Expiry, 2*time.Second)
|
||||
require.WithinDuration(t, token.Expiry.Time(), secondToken.Expiry.Time(), 2*time.Second)
|
||||
secondToken.Expiry = token.Expiry
|
||||
require.Equal(t, token, secondToken)
|
||||
}
|
||||
@ -540,13 +544,16 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
// App name differs
|
||||
AppSlugOrPort: appNamePublic,
|
||||
}).Normalize(),
|
||||
Expiry: time.Now().Add(time.Minute),
|
||||
RegisteredClaims: jwtutils.RegisteredClaims{
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Minute)),
|
||||
},
|
||||
UserID: me.ID,
|
||||
WorkspaceID: workspace.ID,
|
||||
AgentID: agentID,
|
||||
AppURL: appURL,
|
||||
}
|
||||
badTokenStr, err := api.AppSecurityKey.SignToken(badToken)
|
||||
|
||||
badTokenStr, err := jwtutils.Sign(ctx, api.AppSigningKeyCache, badToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := (workspaceapps.Request{
|
||||
@ -589,7 +596,8 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, cookies[0].Name, codersdk.SignedAppTokenCookie)
|
||||
require.NotEqual(t, cookies[0].Value, badTokenStr)
|
||||
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookies[0].Value)
|
||||
var parsedToken workspaceapps.SignedToken
|
||||
err = jwtutils.Verify(ctx, api.AppSigningKeyCache, cookies[0].Value, &parsedToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
|
||||
})
|
||||
|
@ -38,7 +38,7 @@ type ResolveRequestOptions struct {
|
||||
|
||||
func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequestOptions) (*SignedToken, bool) {
|
||||
appReq := opts.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
err := appReq.Check()
|
||||
if err != nil {
|
||||
// This is a 500 since it's a coder server or proxy that's making this
|
||||
// request struct based on details from the request. The values should
|
||||
@ -79,7 +79,7 @@ func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequest
|
||||
Name: codersdk.SignedAppTokenCookie,
|
||||
Value: tokenStr,
|
||||
Path: appReq.BasePath,
|
||||
Expires: token.Expiry,
|
||||
Expires: token.Expiry.Time(),
|
||||
})
|
||||
|
||||
return token, true
|
||||
|
@ -11,17 +11,21 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
||||
@ -97,8 +101,8 @@ type Server struct {
|
||||
HostnameRegex *regexp.Regexp
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
AppSecurityKey SecurityKey
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
APIKeyEncryptionKeycache cryptokeys.EncryptionKeycache
|
||||
|
||||
// DisablePathApps disables path-based apps. This is a security feature as path
|
||||
// based apps share the same cookie as the dashboard, and are susceptible to XSS
|
||||
@ -176,7 +180,10 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
// Exchange the encoded API key for a real one.
|
||||
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
|
||||
var payload EncryptedAPIKeyPayload
|
||||
err := jwtutils.Decrypt(ctx, s.APIKeyEncryptionKeycache, encryptedAPIKey, &payload, jwtutils.WithDecryptExpected(jwt.Expected{
|
||||
Time: time.Now(),
|
||||
}))
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "could not decrypt smuggled workspace app API key", slog.Error(err))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
@ -225,7 +232,7 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
|
||||
// server using the wrong value.
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: AppConnectSessionTokenCookieName(accessMethod),
|
||||
Value: token,
|
||||
Value: payload.APIKey,
|
||||
Domain: domain,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
|
@ -124,9 +124,9 @@ func (r Request) Normalize() Request {
|
||||
return req
|
||||
}
|
||||
|
||||
// Validate ensures the request is correct and contains the necessary
|
||||
// Check ensures the request is correct and contains the necessary
|
||||
// parameters.
|
||||
func (r Request) Validate() error {
|
||||
func (r Request) Check() error {
|
||||
switch r.AccessMethod {
|
||||
case AccessMethodPath, AccessMethodSubdomain, AccessMethodTerminal:
|
||||
default:
|
||||
|
@ -279,7 +279,7 @@ func Test_RequestValidate(t *testing.T) {
|
||||
if !c.noNormalize {
|
||||
req = c.req.Normalize()
|
||||
}
|
||||
err := req.Validate()
|
||||
err := req.Check()
|
||||
if c.errContains == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
|
@ -1,35 +1,27 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenSigningAlgorithm = jose.HS512
|
||||
apiKeyEncryptionAlgorithm = jose.A256GCMKW
|
||||
)
|
||||
|
||||
// SignedToken is the struct data contained inside a workspace app JWE. It
|
||||
// contains the details of the workspace app that the token is valid for to
|
||||
// avoid database queries.
|
||||
type SignedToken struct {
|
||||
jwtutils.RegisteredClaims
|
||||
// Request details.
|
||||
Request `json:"request"`
|
||||
|
||||
// Trusted resolved details.
|
||||
Expiry time.Time `json:"expiry"` // set by GenerateToken if unset
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
@ -57,191 +49,32 @@ func (t SignedToken) MatchesRequest(req Request) bool {
|
||||
t.AppSlugOrPort == req.AppSlugOrPort
|
||||
}
|
||||
|
||||
// SecurityKey is used for signing and encrypting app tokens and API keys.
|
||||
//
|
||||
// The first 64 bytes of the key are used for signing tokens with HMAC-SHA256,
|
||||
// and the last 32 bytes are used for encrypting API keys with AES-256-GCM.
|
||||
// We use a single key for both operations to avoid having to store and manage
|
||||
// two keys.
|
||||
type SecurityKey [96]byte
|
||||
|
||||
func (k SecurityKey) IsZero() bool {
|
||||
return k == SecurityKey{}
|
||||
}
|
||||
|
||||
func (k SecurityKey) String() string {
|
||||
return hex.EncodeToString(k[:])
|
||||
}
|
||||
|
||||
func (k SecurityKey) signingKey() []byte {
|
||||
return k[:64]
|
||||
}
|
||||
|
||||
func (k SecurityKey) encryptionKey() []byte {
|
||||
return k[64:]
|
||||
}
|
||||
|
||||
func KeyFromString(str string) (SecurityKey, error) {
|
||||
var key SecurityKey
|
||||
decoded, err := hex.DecodeString(str)
|
||||
if err != nil {
|
||||
return key, xerrors.Errorf("decode key: %w", err)
|
||||
}
|
||||
if len(decoded) != len(key) {
|
||||
return key, xerrors.Errorf("expected key to be %d bytes, got %d", len(key), len(decoded))
|
||||
}
|
||||
copy(key[:], decoded)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SignToken generates a signed workspace app token with the given payload. If
|
||||
// the payload doesn't have an expiry, it will be set to the current time plus
|
||||
// the default expiry.
|
||||
func (k SecurityKey) SignToken(payload SignedToken) (string, error) {
|
||||
if payload.Expiry.IsZero() {
|
||||
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: tokenSigningAlgorithm,
|
||||
Key: k.signingKey(),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("create signer: %w", err)
|
||||
}
|
||||
|
||||
signedObject, err := signer.Sign(payloadBytes)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("sign payload: %w", err)
|
||||
}
|
||||
|
||||
serialized, err := signedObject.CompactSerialize()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("serialize JWS: %w", err)
|
||||
}
|
||||
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
// VerifySignedToken parses a signed workspace app token with the given key and
|
||||
// returns the payload. If the token is invalid or expired, an error is
|
||||
// returned.
|
||||
func (k SecurityKey) VerifySignedToken(str string) (SignedToken, error) {
|
||||
object, err := jose.ParseSigned(str)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
|
||||
}
|
||||
if len(object.Signatures) != 1 {
|
||||
return SignedToken{}, xerrors.New("expected 1 signature")
|
||||
}
|
||||
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
|
||||
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
|
||||
}
|
||||
|
||||
output, err := object.Verify(k.signingKey())
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
|
||||
}
|
||||
|
||||
var tok SignedToken
|
||||
err = json.Unmarshal(output, &tok)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if tok.Expiry.Before(time.Now()) {
|
||||
return SignedToken{}, xerrors.New("signed app token expired")
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
type EncryptedAPIKeyPayload struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
jwtutils.RegisteredClaims
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// EncryptAPIKey encrypts an API key for subdomain token smuggling.
|
||||
func (k SecurityKey) EncryptAPIKey(payload EncryptedAPIKeyPayload) (string, error) {
|
||||
if payload.APIKey == "" {
|
||||
return "", xerrors.New("API key is empty")
|
||||
}
|
||||
if payload.ExpiresAt.IsZero() {
|
||||
// Very short expiry as these keys are only used once as part of an
|
||||
// automatic redirection flow.
|
||||
payload.ExpiresAt = dbtime.Now().Add(time.Minute)
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// JWEs seem to apply a nonce themselves.
|
||||
encrypter, err := jose.NewEncrypter(
|
||||
jose.A256GCM,
|
||||
jose.Recipient{
|
||||
Algorithm: apiKeyEncryptionAlgorithm,
|
||||
Key: k.encryptionKey(),
|
||||
},
|
||||
&jose.EncrypterOptions{
|
||||
Compression: jose.DEFLATE,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("initializer jose encrypter: %w", err)
|
||||
}
|
||||
encryptedObject, err := encrypter.Encrypt(payloadBytes)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("encrypt jwe: %w", err)
|
||||
}
|
||||
|
||||
encrypted := encryptedObject.FullSerialize()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), nil
|
||||
func (e *EncryptedAPIKeyPayload) Fill(now time.Time) {
|
||||
e.Issuer = "coderd"
|
||||
e.Audience = jwt.Audience{"wsproxy"}
|
||||
e.Expiry = jwt.NewNumericDate(now.Add(time.Minute))
|
||||
e.NotBefore = jwt.NewNumericDate(now.Add(-time.Minute))
|
||||
}
|
||||
|
||||
// DecryptAPIKey undoes EncryptAPIKey and is used in the subdomain app handler.
|
||||
func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
|
||||
encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("base64 decode encrypted API key: %w", err)
|
||||
func (e EncryptedAPIKeyPayload) Validate(ex jwt.Expected) error {
|
||||
if e.NotBefore == nil {
|
||||
return xerrors.Errorf("not before is required")
|
||||
}
|
||||
|
||||
object, err := jose.ParseEncrypted(string(encrypted))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse encrypted API key: %w", err)
|
||||
}
|
||||
if object.Header.Algorithm != string(apiKeyEncryptionAlgorithm) {
|
||||
return "", xerrors.Errorf("expected API key encryption algorithm to be %q, got %q", apiKeyEncryptionAlgorithm, object.Header.Algorithm)
|
||||
}
|
||||
ex.Issuer = "coderd"
|
||||
ex.AnyAudience = jwt.Audience{"wsproxy"}
|
||||
|
||||
// Decrypt using the hashed secret.
|
||||
decrypted, err := object.Decrypt(k.encryptionKey())
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("decrypt API key: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the payload.
|
||||
var payload EncryptedAPIKeyPayload
|
||||
if err := json.Unmarshal(decrypted, &payload); err != nil {
|
||||
return "", xerrors.Errorf("unmarshal decrypted payload: %w", err)
|
||||
}
|
||||
|
||||
// Validate expiry.
|
||||
if payload.ExpiresAt.Before(dbtime.Now()) {
|
||||
return "", xerrors.New("encrypted API key expired")
|
||||
}
|
||||
|
||||
return payload.APIKey, nil
|
||||
return e.RegisteredClaims.Validate(ex)
|
||||
}
|
||||
|
||||
// FromRequest returns the signed token from the request, if it exists and is
|
||||
// valid. The caller must check that the token matches the request.
|
||||
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
||||
func FromRequest(r *http.Request, mgr cryptokeys.SigningKeycache) (*SignedToken, bool) {
|
||||
// Get all signed app tokens from the request. This includes the query
|
||||
// parameter and all matching cookies sent with the request. If there are
|
||||
// somehow multiple signed app token cookies, we want to try all of them
|
||||
@ -270,8 +103,12 @@ func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
||||
tokens = tokens[:4]
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
for _, tokenStr := range tokens {
|
||||
token, err := key.VerifySignedToken(tokenStr)
|
||||
var token SignedToken
|
||||
err := jwtutils.Verify(ctx, mgr, tokenStr, &token, jwtutils.WithVerifyExpected(jwt.Expected{
|
||||
Time: time.Now(),
|
||||
}))
|
||||
if err == nil {
|
||||
req := token.Request.Normalize()
|
||||
if hasQueryParam && req.AccessMethod != AccessMethodTerminal {
|
||||
@ -280,7 +117,7 @@ func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
err := req.Validate()
|
||||
err := req.Check()
|
||||
if err == nil {
|
||||
// The request has a valid signed app token, which is a valid
|
||||
// token signed by us. The caller must check that it matches
|
||||
|
@ -1,22 +1,22 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
func Test_TokenMatchesRequest(t *testing.T) {
|
||||
@ -283,129 +283,6 @@ func Test_TokenMatchesRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_FromRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@ -419,7 +296,13 @@ func Test_FromRequest(t *testing.T) {
|
||||
Value: "invalid",
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
signer := newSigner(t)
|
||||
|
||||
token := workspaceapps.SignedToken{
|
||||
RegisteredClaims: jwtutils.RegisteredClaims{
|
||||
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
Request: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
@ -429,7 +312,6 @@ func Test_FromRequest(t *testing.T) {
|
||||
AgentNameOrID: "agent",
|
||||
AppSlugOrPort: "app",
|
||||
},
|
||||
Expiry: time.Now().Add(time.Hour),
|
||||
UserID: uuid.New(),
|
||||
WorkspaceID: uuid.New(),
|
||||
AgentID: uuid.New(),
|
||||
@ -438,16 +320,15 @@ func Test_FromRequest(t *testing.T) {
|
||||
|
||||
// Add an expired cookie
|
||||
expired := token
|
||||
expired.Expiry = time.Now().Add(time.Hour * -1)
|
||||
expiredStr, err := coderdtest.AppSecurityKey.SignToken(token)
|
||||
expired.RegisteredClaims.Expiry = jwt.NewNumericDate(time.Now().Add(time.Hour * -1))
|
||||
expiredStr, err := jwtutils.Sign(ctx, signer, expired)
|
||||
require.NoError(t, err)
|
||||
r.AddCookie(&http.Cookie{
|
||||
Name: codersdk.SignedAppTokenCookie,
|
||||
Value: expiredStr,
|
||||
})
|
||||
|
||||
// Add a valid token
|
||||
validStr, err := coderdtest.AppSecurityKey.SignToken(token)
|
||||
validStr, err := jwtutils.Sign(ctx, signer, token)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.AddCookie(&http.Cookie{
|
||||
@ -455,147 +336,27 @@ func Test_FromRequest(t *testing.T) {
|
||||
Value: validStr,
|
||||
})
|
||||
|
||||
signed, ok := workspaceapps.FromRequest(r, coderdtest.AppSecurityKey)
|
||||
signed, ok := workspaceapps.FromRequest(r, signer)
|
||||
require.True(t, ok, "expected a token to be found")
|
||||
// Confirm it is the correct token.
|
||||
require.Equal(t, signed.UserID, token.UserID)
|
||||
})
|
||||
}
|
||||
|
||||
// The ParseToken fn is tested quite thoroughly in the GenerateToken test as
|
||||
// well.
|
||||
func Test_ParseToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
func newSigner(t *testing.T) jwtutils.StaticKey {
|
||||
t.Helper()
|
||||
|
||||
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)
|
||||
return jwtutils.StaticKey{
|
||||
ID: "test",
|
||||
Key: generateSecret(t, 64),
|
||||
}
|
||||
|
||||
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: dbtime.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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func generateSecret(t *testing.T, size int) []byte {
|
||||
t.Helper()
|
||||
|
||||
secret := make([]byte, size)
|
||||
_, err := rand.Read(secret)
|
||||
require.NoError(t, err)
|
||||
return secret
|
||||
}
|
||||
|
Reference in New Issue
Block a user