mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
chore: add workspace proxies to the backend (#7032)
Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
@ -11,6 +11,7 @@ import (
|
||||
"net/http/cookiejar"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -24,6 +25,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
@ -31,16 +33,16 @@ import (
|
||||
// Run runs the entire workspace app test suite against deployments minted
|
||||
// by the provided factory.
|
||||
func Run(t *testing.T, factory DeploymentFactory) {
|
||||
setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *AppDetails {
|
||||
setupProxyTest := func(t *testing.T, opts *DeploymentOptions) *Details {
|
||||
return setupProxyTestWithFactory(t, factory, opts)
|
||||
}
|
||||
|
||||
t.Run("ReconnectingPTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
// This might be our implementation, or ConPTY itself.
|
||||
// It's difficult to find extensive tests for it, so
|
||||
// it seems like it could be either.
|
||||
// This might be our implementation, or ConPTY itself. It's
|
||||
// difficult to find extensive tests for it, so it seems like it
|
||||
// could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
|
||||
@ -51,9 +53,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// Run the test against the path app hostname since that's where the
|
||||
// reconnecting-pty proxy server we want to test is mounted.
|
||||
client := codersdk.New(appDetails.PathAppBaseURL)
|
||||
client.SetSessionToken(appDetails.Client.SessionToken())
|
||||
|
||||
client := appDetails.AppClient(t)
|
||||
conn, err := client.WorkspaceAgentReconnectingPTY(ctx, appDetails.Agent.ID, uuid.New(), 80, 80, "/bin/bash")
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
@ -115,7 +115,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
@ -124,40 +124,79 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
require.Contains(t, string(body), "Path-based applications are disabled")
|
||||
})
|
||||
|
||||
t.Run("LoginWithoutAuth", func(t *testing.T) {
|
||||
t.Run("LoginWithoutAuthOnPrimary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Clone the client to strip auth.
|
||||
unauthedClient := codersdk.New(appDetails.Client.URL)
|
||||
unauthedClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
if !appDetails.AppHostIsPrimary {
|
||||
t.Skip("This test only applies when testing apps on the primary.")
|
||||
}
|
||||
|
||||
unauthedClient := appDetails.AppClient(t)
|
||||
unauthedClient.SetSessionToken("")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner).String()
|
||||
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.True(t, loc.Query().Has("message"))
|
||||
require.True(t, loc.Query().Has("redirect"))
|
||||
})
|
||||
|
||||
t.Run("NoAccessShould404", func(t *testing.T) {
|
||||
t.Run("LoginWithoutAuthOnProxy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
if appDetails.AppHostIsPrimary {
|
||||
t.Skip("This test only applies when testing apps on workspace proxies.")
|
||||
}
|
||||
|
||||
unauthedClient := appDetails.AppClient(t)
|
||||
unauthedClient.SetSessionToken("")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
resp, err := requestWithRetries(ctx, t, unauthedClient, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.SDKClient.URL.Host, loc.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
|
||||
|
||||
redirectURIStr := loc.Query().Get("redirect_uri")
|
||||
require.NotEmpty(t, redirectURIStr)
|
||||
redirectURI, err := url.Parse(redirectURIStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, u.Scheme, redirectURI.Scheme)
|
||||
require.Equal(t, u.Host, redirectURI.Host)
|
||||
// TODO(@dean): I have no idea how but the trailing slash on this
|
||||
// request is getting stripped.
|
||||
require.Equal(t, u.Path, redirectURI.Path+"/")
|
||||
require.Equal(t, u.RawQuery, redirectURI.RawQuery)
|
||||
})
|
||||
|
||||
t.Run("NoAccessShould404", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userAppClient := appDetails.AppClient(t)
|
||||
userAppClient.SetSessionToken(userClient.SessionToken())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@ -169,9 +208,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
@ -183,9 +222,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.PathAppURL(appDetails.Apps.Owner)
|
||||
u.RawQuery = ""
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
@ -200,8 +239,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.PathAppURL(appDetails.OwnerApp)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
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)
|
||||
@ -220,9 +259,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
require.Equal(t, appTokenCookie.Path, u.Path, "incorrect path on app token cookie")
|
||||
|
||||
// Ensure the signed app token cookie is valid.
|
||||
appTokenClient := codersdk.New(appDetails.Client.URL)
|
||||
appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
appTokenClient := appDetails.AppClient(t)
|
||||
appTokenClient.SetSessionToken("")
|
||||
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
|
||||
@ -242,10 +280,10 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
app := appDetails.OwnerApp
|
||||
app := appDetails.Apps.Owner
|
||||
app.Username = codersdk.Me
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(app).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(app).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@ -261,7 +299,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.PathAppURL(appDetails.OwnerApp).String(), nil, func(r *http.Request) {
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Owner).String(), nil, func(r *http.Request) {
|
||||
r.Header.Set("Cf-Connecting-IP", "1.1.1.1")
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@ -279,7 +317,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.FakeApp).String(), nil)
|
||||
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Fake).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
||||
@ -291,7 +329,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.PortApp).String(), nil)
|
||||
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.PathAppURL(appDetails.Apps.Port).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
// TODO(@deansheather): This should be 400. There's a todo in the
|
||||
@ -309,187 +347,186 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
appDetails := setupProxyTest(t, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Get the current user and API key.
|
||||
user, err := appDetails.Client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
currentAPIKey, err := appDetails.Client.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.Client.SessionToken(), "-")[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to load the application without authentication.
|
||||
subdomain := fmt.Sprintf("%s--%s--%s--%s", proxyTestAppNameOwner, proxyTestAgentName, appDetails.Workspace.Name, user.Username)
|
||||
u, err := url.Parse(fmt.Sprintf("http://%s.%s/test", subdomain, proxyTestSubdomain))
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var resp *http.Response
|
||||
resp, err = doWithRetries(t, appDetails.Client, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
|
||||
// Check that the Location is correct.
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
gotLocation, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.Client.URL.Host, gotLocation.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
|
||||
require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))
|
||||
|
||||
// Load the application auth-redirect endpoint.
|
||||
resp, err = requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
|
||||
"redirect_uri", u.String(),
|
||||
))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy the query parameters and then check equality.
|
||||
u.RawQuery = gotLocation.RawQuery
|
||||
require.Equal(t, u, gotLocation)
|
||||
|
||||
// Verify the API key is set.
|
||||
var encryptedAPIKey string
|
||||
for k, v := range gotLocation.Query() {
|
||||
// The query parameter may change dynamically in the future and is
|
||||
// not exported, so we just use a fuzzy check instead.
|
||||
if strings.Contains(k, "api_key") {
|
||||
encryptedAPIKey = v[0]
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
|
||||
|
||||
// Decrypt the API key by following the request.
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
resp, err = doWithRetries(t, appDetails.Client, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
cookies := resp.Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
apiKey := cookies[0].Value
|
||||
|
||||
// Fetch the API key.
|
||||
apiKeyInfo, err := appDetails.Client.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(apiKey, "-")[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, apiKeyInfo.UserID)
|
||||
require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
|
||||
require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)
|
||||
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)
|
||||
|
||||
// Verify the API key permissions
|
||||
appClient := codersdk.New(appDetails.Client.URL)
|
||||
appClient.SetSessionToken(apiKey)
|
||||
appClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
appClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
|
||||
var (
|
||||
canCreateApplicationConnect = "can-create-application_connect"
|
||||
canReadUserMe = "can-read-user-me"
|
||||
)
|
||||
authRes, err := appClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
|
||||
Checks: map[string]codersdk.AuthorizationCheck{
|
||||
canCreateApplicationConnect: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "application_connect",
|
||||
OwnerID: "me",
|
||||
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
|
||||
},
|
||||
Action: "create",
|
||||
},
|
||||
canReadUserMe: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "user",
|
||||
OwnerID: "me",
|
||||
ResourceID: appDetails.FirstUser.UserID.String(),
|
||||
},
|
||||
Action: "read",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, authRes[canCreateApplicationConnect])
|
||||
require.False(t, authRes[canReadUserMe])
|
||||
|
||||
// Load the application page with the API key set.
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set(codersdk.SessionTokenHeader, apiKey)
|
||||
resp, err = doWithRetries(t, appDetails.Client, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("VerifyRedirectURI", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
appDetails := setupProxyTest(t, nil)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
redirectURI string
|
||||
status int
|
||||
messageContains string
|
||||
name string
|
||||
appURL *url.URL
|
||||
verifyCookie func(t *testing.T, c *http.Cookie)
|
||||
}{
|
||||
{
|
||||
name: "NoRedirectURI",
|
||||
redirectURI: "",
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "Missing redirect_uri query parameter",
|
||||
name: "Subdomain",
|
||||
appURL: appDetails.SubdomainAppURL(appDetails.Apps.Owner),
|
||||
verifyCookie: func(t *testing.T, c *http.Cookie) {
|
||||
// TODO(@dean): fix these asserts, they don't seem to
|
||||
// work. I wonder if Go strips the domain from the
|
||||
// cookie object if it's invalid or something.
|
||||
// domain := strings.SplitN(appDetails.Options.AppHost, ".", 2)
|
||||
// require.Equal(t, "."+domain[1], c.Domain, "incorrect domain on app token cookie")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InvalidURI",
|
||||
redirectURI: "not a url",
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "Invalid redirect_uri query parameter",
|
||||
},
|
||||
{
|
||||
name: "NotMatchAppHostname",
|
||||
redirectURI: "https://app--agent--workspace--user.not-a-match.com",
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "The redirect_uri query parameter must be a valid app subdomain",
|
||||
},
|
||||
{
|
||||
name: "InvalidAppURL",
|
||||
redirectURI: "https://not-an-app." + proxyTestSubdomain,
|
||||
status: http.StatusBadRequest,
|
||||
messageContains: "The redirect_uri query parameter must be a valid app subdomain",
|
||||
name: "Path",
|
||||
appURL: appDetails.PathAppURL(appDetails.Apps.Owner),
|
||||
verifyCookie: func(t *testing.T, c *http.Cookie) {
|
||||
// TODO(@dean): fix these asserts, they don't seem to
|
||||
// work. I wonder if Go strips the domain from the
|
||||
// cookie object if it's invalid or something.
|
||||
// require.Equal(t, "", c.Domain, "incorrect domain on app token cookie")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
if c.name == "Path" && appDetails.AppHostIsPrimary {
|
||||
// Workspace application auth does not apply to path apps
|
||||
// served from the primary access URL as no smuggling needs
|
||||
// to take place (they're already logged in with a session
|
||||
// token).
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, "/api/v2/applications/auth-redirect", nil,
|
||||
codersdk.WithQueryParam("redirect_uri", c.redirectURI),
|
||||
)
|
||||
// Get the current user and API key.
|
||||
user, err := appDetails.SDKClient.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
currentAPIKey, err := appDetails.SDKClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(appDetails.SDKClient.SessionToken(), "-")[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
appClient := appDetails.AppClient(t)
|
||||
appClient.SetSessionToken("")
|
||||
|
||||
// Try to load the application without authentication.
|
||||
u := c.appURL
|
||||
u.Path = path.Join(u.Path, "/test")
|
||||
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)
|
||||
|
||||
if !assert.Equal(t, http.StatusSeeOther, resp.StatusCode) {
|
||||
dump, err := httputil.DumpResponse(resp, true)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(dump))
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Check that the Location is correct.
|
||||
gotLocation, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
// This should always redirect to the primary access URL.
|
||||
require.Equal(t, appDetails.SDKClient.URL.Host, gotLocation.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", gotLocation.Path)
|
||||
require.Equal(t, u.String(), gotLocation.Query().Get("redirect_uri"))
|
||||
|
||||
// Load the application auth-redirect endpoint.
|
||||
resp, err = requestWithRetries(ctx, t, appDetails.SDKClient, http.MethodGet, "/api/v2/applications/auth-redirect", nil, codersdk.WithQueryParam(
|
||||
"redirect_uri", u.String(),
|
||||
))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy the query parameters and then check equality.
|
||||
u.RawQuery = gotLocation.RawQuery
|
||||
require.Equal(t, u, gotLocation)
|
||||
|
||||
// Verify the API key is set.
|
||||
encryptedAPIKey := gotLocation.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
|
||||
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
|
||||
|
||||
// Decrypt the API key by following the request.
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
resp, err = doWithRetries(t, appClient, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
|
||||
cookies := resp.Cookies()
|
||||
var cookie *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == codersdk.DevURLSessionTokenCookie {
|
||||
cookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, cookie, "no app session token cookie was set")
|
||||
c.verifyCookie(t, cookie)
|
||||
apiKey := cookie.Value
|
||||
|
||||
// Fetch the API key from the API.
|
||||
apiKeyInfo, err := appDetails.SDKClient.APIKeyByID(ctx, appDetails.FirstUser.UserID.String(), strings.Split(apiKey, "-")[0])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, user.ID, apiKeyInfo.UserID)
|
||||
require.Equal(t, codersdk.LoginTypePassword, apiKeyInfo.LoginType)
|
||||
require.WithinDuration(t, currentAPIKey.ExpiresAt, apiKeyInfo.ExpiresAt, 5*time.Second)
|
||||
require.EqualValues(t, currentAPIKey.LifetimeSeconds, apiKeyInfo.LifetimeSeconds)
|
||||
|
||||
// Verify the API key permissions
|
||||
appTokenAPIClient := codersdk.New(appDetails.SDKClient.URL)
|
||||
appTokenAPIClient.SetSessionToken(apiKey)
|
||||
appTokenAPIClient.HTTPClient.CheckRedirect = appDetails.SDKClient.HTTPClient.CheckRedirect
|
||||
appTokenAPIClient.HTTPClient.Transport = appDetails.SDKClient.HTTPClient.Transport
|
||||
|
||||
var (
|
||||
canCreateApplicationConnect = "can-create-application_connect"
|
||||
canReadUserMe = "can-read-user-me"
|
||||
)
|
||||
authRes, err := appTokenAPIClient.AuthCheck(ctx, codersdk.AuthorizationRequest{
|
||||
Checks: map[string]codersdk.AuthorizationCheck{
|
||||
canCreateApplicationConnect: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "application_connect",
|
||||
OwnerID: "me",
|
||||
OrganizationID: appDetails.FirstUser.OrganizationID.String(),
|
||||
},
|
||||
Action: "create",
|
||||
},
|
||||
canReadUserMe: {
|
||||
Object: codersdk.AuthorizationObject{
|
||||
ResourceType: "user",
|
||||
OwnerID: "me",
|
||||
ResourceID: appDetails.FirstUser.UserID.String(),
|
||||
},
|
||||
Action: "read",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, authRes[canCreateApplicationConnect])
|
||||
require.False(t, authRes[canReadUserMe])
|
||||
|
||||
// Load the application page with the API key set.
|
||||
gotLocation, err = resp.Location()
|
||||
require.NoError(t, err)
|
||||
t.Log("navigating to: ", gotLocation.String())
|
||||
req, err = http.NewRequestWithContext(ctx, "GET", gotLocation.String(), nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set(codersdk.SessionTokenHeader, apiKey)
|
||||
resp, err = doWithRetries(t, appClient, req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// This test ensures that the subdomain handler does nothing if --app-hostname
|
||||
// is not set by the admin.
|
||||
// This test ensures that the subdomain handler does nothing if
|
||||
// --app-hostname is not set by the admin.
|
||||
t.Run("WorkspaceAppsProxySubdomainPassthrough", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@ -499,12 +536,17 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
DisableSubdomainApps: true,
|
||||
noWorkspace: true,
|
||||
})
|
||||
if !appDetails.AppHostIsPrimary {
|
||||
t.Skip("app hostname does not serve API")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
uri := fmt.Sprintf("http://app--agent--workspace--username.%s/api/v2/users/me", proxyTestSubdomain)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, uri, nil)
|
||||
u := *appDetails.SDKClient.URL
|
||||
u.Host = "app--agent--workspace--username.test.coder.com"
|
||||
u.Path = "/api/v2/users/me"
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
@ -535,7 +577,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
host := strings.Replace(appDetails.Options.AppHost, "*", "not-an-app-subdomain", 1)
|
||||
uri := fmt.Sprintf("http://%s/api/v2/users/me", host)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, uri, nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, uri, nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
@ -555,14 +597,14 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
t.Run("NoAccessShould401", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.Client, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
userClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
userClient, _ := coderdtest.CreateAnotherUser(t, appDetails.SDKClient, appDetails.FirstUser.OrganizationID, rbac.RoleMember())
|
||||
userAppClient := appDetails.AppClient(t)
|
||||
userAppClient.SetSessionToken(userClient.SessionToken())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.OwnerApp).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, userAppClient, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Owner).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@ -574,17 +616,17 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.OwnerApp).Path, loc.Path)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.Apps.Owner).Path, loc.Path)
|
||||
})
|
||||
|
||||
t.Run("RedirectsWithQuery", func(t *testing.T) {
|
||||
@ -593,16 +635,16 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
u.RawQuery = ""
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
|
||||
loc, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.OwnerApp).RawQuery, loc.RawQuery)
|
||||
require.Equal(t, appDetails.SubdomainAppURL(appDetails.Apps.Owner).RawQuery, loc.RawQuery)
|
||||
})
|
||||
|
||||
t.Run("Proxies", func(t *testing.T) {
|
||||
@ -611,8 +653,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
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)
|
||||
@ -630,10 +672,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
require.NotNil(t, appTokenCookie, "no signed token cookie in response")
|
||||
require.Equal(t, appTokenCookie.Path, "/", "incorrect path on signed token cookie")
|
||||
|
||||
// Ensure the session token cookie is valid.
|
||||
appTokenClient := codersdk.New(appDetails.Client.URL)
|
||||
appTokenClient.HTTPClient.CheckRedirect = appDetails.Client.HTTPClient.CheckRedirect
|
||||
appTokenClient.HTTPClient.Transport = appDetails.Client.HTTPClient.Transport
|
||||
// Ensure the signed app token cookie is valid.
|
||||
appTokenClient := appDetails.AppClient(t)
|
||||
appTokenClient.SetSessionToken("")
|
||||
appTokenClient.HTTPClient.Jar, err = cookiejar.New(nil)
|
||||
require.NoError(t, err)
|
||||
appTokenClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{appTokenCookie})
|
||||
@ -653,7 +694,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(appDetails.PortApp).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Port).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@ -668,7 +709,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
resp, err := appDetails.Client.Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.FakeApp).String(), nil)
|
||||
resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, appDetails.SubdomainAppURL(appDetails.Apps.Fake).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
||||
@ -680,9 +721,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
app := appDetails.PortApp
|
||||
app := appDetails.Apps.Port
|
||||
app.AppSlugOrPort = strconv.Itoa(codersdk.WorkspaceAgentMinimumListeningPort - 1)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, appDetails.SubdomainAppURL(app).String(), nil)
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.AppClient(t), http.MethodGet, appDetails.SubdomainAppURL(app).String(), nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
@ -704,10 +745,10 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
t.Logf("url: %s", u)
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
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)
|
||||
@ -727,19 +768,19 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
// Replace the -suffix with nothing.
|
||||
u.Host = strings.Replace(u.Host, "-suffix", "", 1)
|
||||
t.Logf("url: %s", u)
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
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)
|
||||
|
||||
// It's probably rendering the dashboard, so only ensure that the body
|
||||
// doesn't match.
|
||||
// It's probably rendering the dashboard or a 404 page, so only
|
||||
// ensure that the body doesn't match.
|
||||
require.NotContains(t, string(body), proxyTestAppBody)
|
||||
})
|
||||
|
||||
@ -749,12 +790,12 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
u := appDetails.SubdomainAppURL(appDetails.OwnerApp)
|
||||
u := appDetails.SubdomainAppURL(appDetails.Apps.Owner)
|
||||
// Replace the -suffix with something else.
|
||||
u.Host = strings.Replace(u.Host, "-suffix", "-not-suffix", 1)
|
||||
t.Logf("url: %s", u)
|
||||
|
||||
resp, err := requestWithRetries(ctx, t, appDetails.Client, http.MethodGet, u.String(), nil)
|
||||
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)
|
||||
@ -770,7 +811,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
t.Run("AppSharing", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (appDetails *AppDetails, workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, ownerClient *codersdk.Client, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) {
|
||||
setup := func(t *testing.T, allowPathAppSharing, allowSiteOwnerAccess bool) (appDetails *Details, workspace codersdk.Workspace, agnt codersdk.WorkspaceAgent, user codersdk.User, ownerClient *codersdk.Client, client *codersdk.Client, clientInOtherOrg *codersdk.Client, clientWithNoAuth *codersdk.Client) {
|
||||
//nolint:gosec
|
||||
const password = "SomeSecurePassword!"
|
||||
|
||||
@ -786,7 +827,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// Create a template-admin user in the same org. We don't use an owner
|
||||
// since they have access to everything.
|
||||
ownerClient = appDetails.Client
|
||||
ownerClient = appDetails.SDKClient
|
||||
user, err := ownerClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "user@coder.com",
|
||||
Username: "user",
|
||||
@ -814,7 +855,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// Create workspace.
|
||||
port := appServer(t)
|
||||
workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, proxyTestSubdomainRaw, port)
|
||||
workspace, _ = createWorkspaceWithApps(t, client, user.OrganizationIDs[0], user, port)
|
||||
|
||||
// Verify that the apps have the correct sharing levels set.
|
||||
workspaceBuild, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
|
||||
@ -869,7 +910,7 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
return appDetails, workspace, agnt, user, ownerClient, client, clientInOtherOrg, clientWithNoAuth
|
||||
}
|
||||
|
||||
verifyAccess := func(t *testing.T, appDetails *AppDetails, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) {
|
||||
verifyAccess := func(t *testing.T, appDetails *Details, isPathApp bool, username, workspaceName, agentName, appName string, client *codersdk.Client, shouldHaveAccess, shouldRedirectToLogin bool) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
@ -877,29 +918,24 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
// If the client has a session token, we also want to check that a
|
||||
// scoped key works.
|
||||
clients := []*codersdk.Client{client}
|
||||
sessionTokens := []string{client.SessionToken()}
|
||||
if client.SessionToken() != "" {
|
||||
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
||||
Scope: codersdk.APIKeyScopeApplicationConnect,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
scopedClient := codersdk.New(client.URL)
|
||||
scopedClient.SetSessionToken(token.Key)
|
||||
scopedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
|
||||
scopedClient.HTTPClient.Transport = client.HTTPClient.Transport
|
||||
|
||||
clients = append(clients, scopedClient)
|
||||
sessionTokens = append(sessionTokens, token.Key)
|
||||
}
|
||||
|
||||
for i, client := range clients {
|
||||
for i, sessionToken := range sessionTokens {
|
||||
msg := fmt.Sprintf("client %d", i)
|
||||
|
||||
app := App{
|
||||
AppSlugOrPort: appName,
|
||||
AgentName: agentName,
|
||||
WorkspaceName: workspaceName,
|
||||
Username: username,
|
||||
WorkspaceName: workspaceName,
|
||||
AgentName: agentName,
|
||||
AppSlugOrPort: appName,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
u := appDetails.SubdomainAppURL(app)
|
||||
@ -907,6 +943,8 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
u = appDetails.PathAppURL(app)
|
||||
}
|
||||
|
||||
client := appDetails.AppClient(t)
|
||||
client.SetSessionToken(sessionToken)
|
||||
res, err := requestWithRetries(ctx, t, client, http.MethodGet, u.String(), nil)
|
||||
require.NoError(t, err, msg)
|
||||
|
||||
@ -918,12 +956,12 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
|
||||
if !shouldHaveAccess {
|
||||
if shouldRedirectToLogin {
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, res.StatusCode, "should not have access, expected temporary redirect. "+msg)
|
||||
assert.Equal(t, http.StatusSeeOther, res.StatusCode, "should not have access, expected See Other redirect. "+msg)
|
||||
location, err := res.Location()
|
||||
require.NoError(t, err, msg)
|
||||
|
||||
expectedPath := "/login"
|
||||
if !isPathApp {
|
||||
if !isPathApp || !appDetails.AppHostIsPrimary {
|
||||
expectedPath = "/api/v2/applications/auth-redirect"
|
||||
}
|
||||
assert.Equal(t, expectedPath, location.Path, "should not have access, expected redirect to applicable login endpoint. "+msg)
|
||||
@ -1103,11 +1141,11 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
}{
|
||||
{
|
||||
name: "ProxyPath",
|
||||
u: appDetails.PathAppURL(appDetails.OwnerApp),
|
||||
u: appDetails.PathAppURL(appDetails.Apps.Owner),
|
||||
},
|
||||
{
|
||||
name: "ProxySubdomain",
|
||||
u: appDetails.SubdomainAppURL(appDetails.OwnerApp),
|
||||
u: appDetails.SubdomainAppURL(appDetails.Apps.Owner),
|
||||
},
|
||||
}
|
||||
|
||||
@ -1132,9 +1170,9 @@ func Run(t *testing.T, factory DeploymentFactory) {
|
||||
// server.
|
||||
secWebSocketKey := "test-dean-was-here"
|
||||
req.Header["Sec-WebSocket-Key"] = []string{secWebSocketKey}
|
||||
req.Header.Set(codersdk.SessionTokenHeader, appDetails.SDKClient.SessionToken())
|
||||
|
||||
req.Header.Set(codersdk.SessionTokenHeader, appDetails.Client.SessionToken())
|
||||
resp, err := doWithRetries(t, appDetails.Client, req)
|
||||
resp, err := doWithRetries(t, appDetails.AppClient(t), req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
@ -58,10 +58,15 @@ type DeploymentOptions struct {
|
||||
type Deployment struct {
|
||||
Options *DeploymentOptions
|
||||
|
||||
// Client should be logged in as the admin user.
|
||||
Client *codersdk.Client
|
||||
// SDKClient should be logged in as the admin user.
|
||||
SDKClient *codersdk.Client
|
||||
FirstUser codersdk.CreateFirstUserResponse
|
||||
PathAppBaseURL *url.URL
|
||||
|
||||
// AppHostIsPrimary is true if the app host is also the primary coder API
|
||||
// server. This disables any tests that test API passthrough or rely on the
|
||||
// app server not being the API server.
|
||||
AppHostIsPrimary bool
|
||||
}
|
||||
|
||||
// DeploymentFactory generates a deployment with an API client, a path base URL,
|
||||
@ -83,8 +88,8 @@ type App struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
// AppDetails are the full test details returned from setupProxyTestWithFactory.
|
||||
type AppDetails struct {
|
||||
// Details are the full test details returned from setupProxyTestWithFactory.
|
||||
type Details struct {
|
||||
*Deployment
|
||||
|
||||
Me codersdk.User
|
||||
@ -96,15 +101,33 @@ type AppDetails struct {
|
||||
Agent *codersdk.WorkspaceAgent
|
||||
AppPort uint16
|
||||
|
||||
FakeApp App
|
||||
OwnerApp App
|
||||
AuthenticatedApp App
|
||||
PublicApp App
|
||||
PortApp App
|
||||
Apps struct {
|
||||
Fake App
|
||||
Owner App
|
||||
Authenticated App
|
||||
Public App
|
||||
Port App
|
||||
}
|
||||
}
|
||||
|
||||
// AppClient returns a *codersdk.Client that will route all requests to the
|
||||
// app server. API requests will fail with this client. Any redirect responses
|
||||
// are not followed by default.
|
||||
//
|
||||
// The client is authenticated as the first user by default.
|
||||
func (d *Details) AppClient(t *testing.T) *codersdk.Client {
|
||||
client := codersdk.New(d.PathAppBaseURL)
|
||||
client.SetSessionToken(d.SDKClient.SessionToken())
|
||||
forceURLTransport(t, client)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// PathAppURL returns the URL for the given path app.
|
||||
func (d *AppDetails) PathAppURL(app App) *url.URL {
|
||||
func (d *Details) PathAppURL(app App) *url.URL {
|
||||
appPath := fmt.Sprintf("/@%s/%s/apps/%s", app.Username, app.WorkspaceName, app.AppSlugOrPort)
|
||||
|
||||
u := *d.PathAppBaseURL
|
||||
@ -115,11 +138,7 @@ func (d *AppDetails) PathAppURL(app App) *url.URL {
|
||||
}
|
||||
|
||||
// SubdomainAppURL returns the URL for the given subdomain app.
|
||||
func (d *AppDetails) SubdomainAppURL(app App) *url.URL {
|
||||
if d.Options.DisableSubdomainApps || d.Options.AppHost == "" {
|
||||
panic("subdomain apps are disabled")
|
||||
}
|
||||
|
||||
func (d *Details) SubdomainAppURL(app App) *url.URL {
|
||||
host := fmt.Sprintf("%s--%s--%s--%s", app.AppSlugOrPort, app.AgentName, app.WorkspaceName, app.Username)
|
||||
|
||||
u := *d.PathAppBaseURL
|
||||
@ -135,7 +154,7 @@ func (d *AppDetails) SubdomainAppURL(app App) *url.URL {
|
||||
// 3. Create a template version, template and workspace with many apps.
|
||||
// 4. Start a workspace agent.
|
||||
// 5. Returns details about the deployment and its apps.
|
||||
func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *DeploymentOptions) *AppDetails {
|
||||
func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *DeploymentOptions) *Details {
|
||||
if opts == nil {
|
||||
opts = &DeploymentOptions{}
|
||||
}
|
||||
@ -150,19 +169,19 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De
|
||||
|
||||
// Configure the HTTP client to not follow redirects and to route all
|
||||
// requests regardless of hostname to the coderd test server.
|
||||
deployment.Client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
deployment.SDKClient.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
forceURLTransport(t, deployment.Client)
|
||||
forceURLTransport(t, deployment.SDKClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
me, err := deployment.Client.User(ctx, codersdk.Me)
|
||||
me, err := deployment.SDKClient.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
if opts.noWorkspace {
|
||||
return &AppDetails{
|
||||
return &Details{
|
||||
Deployment: deployment,
|
||||
Me: me,
|
||||
}
|
||||
@ -171,49 +190,51 @@ func setupProxyTestWithFactory(t *testing.T, factory DeploymentFactory, opts *De
|
||||
if opts.port == 0 {
|
||||
opts.port = appServer(t)
|
||||
}
|
||||
workspace, agnt := createWorkspaceWithApps(t, deployment.Client, deployment.FirstUser.OrganizationID, me, opts.AppHost, opts.port)
|
||||
workspace, agnt := createWorkspaceWithApps(t, deployment.SDKClient, deployment.FirstUser.OrganizationID, me, opts.port)
|
||||
|
||||
return &AppDetails{
|
||||
details := &Details{
|
||||
Deployment: deployment,
|
||||
Me: me,
|
||||
Workspace: &workspace,
|
||||
Agent: &agnt,
|
||||
AppPort: opts.port,
|
||||
|
||||
FakeApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameFake,
|
||||
},
|
||||
OwnerApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameOwner,
|
||||
Query: proxyTestAppQuery,
|
||||
},
|
||||
AuthenticatedApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameAuthenticated,
|
||||
Query: proxyTestAppQuery,
|
||||
},
|
||||
PublicApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNamePublic,
|
||||
Query: proxyTestAppQuery,
|
||||
},
|
||||
PortApp: App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: strconv.Itoa(int(opts.port)),
|
||||
},
|
||||
}
|
||||
|
||||
details.Apps.Fake = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameFake,
|
||||
}
|
||||
details.Apps.Owner = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameOwner,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
details.Apps.Authenticated = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNameAuthenticated,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
details.Apps.Public = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: proxyTestAppNamePublic,
|
||||
Query: proxyTestAppQuery,
|
||||
}
|
||||
details.Apps.Port = App{
|
||||
Username: me.Username,
|
||||
WorkspaceName: workspace.Name,
|
||||
AgentName: agnt.Name,
|
||||
AppSlugOrPort: strconv.Itoa(int(opts.port)),
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func appServer(t *testing.T) uint16 {
|
||||
@ -259,7 +280,7 @@ func appServer(t *testing.T) uint16 {
|
||||
return uint16(tcpAddr.Port)
|
||||
}
|
||||
|
||||
func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, appHost string, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) {
|
||||
func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.UUID, me codersdk.User, port uint16, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (codersdk.Workspace, codersdk.WorkspaceAgent) {
|
||||
authToken := uuid.NewString()
|
||||
|
||||
appURL := fmt.Sprintf("http://127.0.0.1:%d?%s", port, proxyTestAppQuery)
|
||||
@ -318,7 +339,18 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
if appHost != "" {
|
||||
|
||||
// TODO (@dean): currently, the primary app host is used when generating
|
||||
// the port URL we tell the agent to use. We don't have any plans to change
|
||||
// that until we let templates pick which proxy they want to use in the
|
||||
// terraform.
|
||||
//
|
||||
// This means that all port URLs generated in code-server etc. will be sent
|
||||
// to the primary.
|
||||
appHostCtx := testutil.Context(t, testutil.WaitLong)
|
||||
primaryAppHost, err := client.AppHost(appHostCtx)
|
||||
require.NoError(t, err)
|
||||
if primaryAppHost.Host != "" {
|
||||
manifest, err := agentClient.Manifest(context.Background())
|
||||
require.NoError(t, err)
|
||||
proxyURL := fmt.Sprintf(
|
||||
@ -326,11 +358,8 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
|
||||
proxyTestAgentName,
|
||||
workspace.Name,
|
||||
me.Username,
|
||||
strings.ReplaceAll(appHost, "*", ""),
|
||||
strings.ReplaceAll(primaryAppHost.Host, "*", ""),
|
||||
)
|
||||
if client.URL.Port() != "" {
|
||||
proxyURL += fmt.Sprintf(":%s", client.URL.Port())
|
||||
}
|
||||
require.Equal(t, proxyURL, manifest.VSCodePortProxyURI)
|
||||
}
|
||||
agentCloser := agent.New(agent.Options{
|
||||
@ -386,7 +415,7 @@ func requestWithRetries(ctx context.Context, t require.TestingT, client *codersd
|
||||
}
|
||||
|
||||
// forceURLTransport forces the client to route all requests to the client's
|
||||
// configured URL host regardless of hostname.
|
||||
// configured URLs host regardless of hostname.
|
||||
func forceURLTransport(t *testing.T, client *codersdk.Client) {
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
require.True(t, ok)
|
||||
|
@ -6,12 +6,13 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
@ -25,8 +26,8 @@ import (
|
||||
type DBTokenProvider struct {
|
||||
Logger slog.Logger
|
||||
|
||||
// AccessURL is the main dashboard access URL for error pages.
|
||||
AccessURL *url.URL
|
||||
// DashboardURL is the main dashboard access URL for error pages.
|
||||
DashboardURL *url.URL
|
||||
Authorizer rbac.Authorizer
|
||||
Database database.Store
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
@ -44,7 +45,7 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
|
||||
|
||||
return &DBTokenProvider{
|
||||
Logger: log,
|
||||
AccessURL: accessURL,
|
||||
DashboardURL: accessURL,
|
||||
Authorizer: authz,
|
||||
Database: db,
|
||||
DeploymentValues: cfg,
|
||||
@ -54,29 +55,11 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
|
||||
}
|
||||
}
|
||||
|
||||
func (p *DBTokenProvider) TokenFromRequest(r *http.Request) (*SignedToken, bool) {
|
||||
// Get the existing token from the request.
|
||||
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
||||
if err == nil {
|
||||
token, err := p.SigningKey.VerifySignedToken(tokenCookie.Value)
|
||||
if err == nil {
|
||||
req := token.Request.Normalize()
|
||||
err := req.Validate()
|
||||
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
|
||||
// the request.
|
||||
return &token, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
func (p *DBTokenProvider) FromRequest(r *http.Request) (*SignedToken, bool) {
|
||||
return FromRequest(r, p.SigningKey)
|
||||
}
|
||||
|
||||
// ResolveRequest takes an app request, checks if it's valid and authenticated,
|
||||
// and returns a token with details about the app.
|
||||
func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, string, bool) {
|
||||
func (p *DBTokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq IssueTokenRequest) (*SignedToken, string, bool) {
|
||||
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
|
||||
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
|
||||
// // dangerousSystemCtx is only used for database calls. The actual authentication
|
||||
@ -84,10 +67,10 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
// // permissions.
|
||||
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
appReq = appReq.Normalize()
|
||||
appReq := issueReq.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "invalid app request")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
@ -102,11 +85,13 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
OAuth2Configs: p.OAuth2Configs,
|
||||
RedirectToLogin: false,
|
||||
DisableSessionExpiryRefresh: p.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
||||
// Optional is true to allow for public apps. If an authorization check
|
||||
// fails and the user is not authenticated, they will be redirected to
|
||||
// the login page using code below (not the redirect from the
|
||||
// middleware itself).
|
||||
// Optional is true to allow for public apps. If the authorization check
|
||||
// (later on) fails and the user is not authenticated, they will be
|
||||
// redirected to the login page or app auth endpoint using code below.
|
||||
Optional: true,
|
||||
SessionTokenFunc: func(r *http.Request) string {
|
||||
return issueReq.SessionToken
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
@ -115,75 +100,110 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
// Lookup workspace app details from DB.
|
||||
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
WriteWorkspaceApp404(p.Logger, p.AccessURL, rw, r, &appReq, err.Error())
|
||||
WriteWorkspaceApp404(p.Logger, p.DashboardURL, rw, r, &appReq, err.Error())
|
||||
return nil, "", false
|
||||
} else if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "get app details from database")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app details from database")
|
||||
return nil, "", false
|
||||
}
|
||||
token.UserID = dbReq.User.ID
|
||||
token.WorkspaceID = dbReq.Workspace.ID
|
||||
token.AgentID = dbReq.Agent.ID
|
||||
token.AppURL = dbReq.AppURL
|
||||
if dbReq.AppURL != nil {
|
||||
token.AppURL = dbReq.AppURL.String()
|
||||
}
|
||||
|
||||
// Verify the user has access to the app.
|
||||
authed, err := p.authorizeRequest(r.Context(), authz, dbReq)
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "verify authz")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "verify authz")
|
||||
return nil, "", false
|
||||
}
|
||||
if !authed {
|
||||
if apiKey != nil {
|
||||
// The request has a valid API key but insufficient permissions.
|
||||
WriteWorkspaceApp404(p.Logger, p.AccessURL, rw, r, &appReq, "insufficient permissions")
|
||||
WriteWorkspaceApp404(p.Logger, p.DashboardURL, rw, r, &appReq, "insufficient permissions")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Redirect to login as they don't have permission to access the app
|
||||
// and they aren't signed in.
|
||||
switch appReq.AccessMethod {
|
||||
case AccessMethodPath:
|
||||
// TODO(@deansheather): this doesn't work on moons so will need to
|
||||
// be updated to include the access URL as a param
|
||||
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
|
||||
case AccessMethodSubdomain:
|
||||
// Redirect to the app auth redirect endpoint with a valid redirect
|
||||
// URI.
|
||||
redirectURI := *r.URL
|
||||
redirectURI.Scheme = p.AccessURL.Scheme
|
||||
redirectURI.Host = httpapi.RequestHost(r)
|
||||
|
||||
u := *p.AccessURL
|
||||
u.Path = "/api/v2/applications/auth-redirect"
|
||||
q := u.Query()
|
||||
q.Add(RedirectURIQueryParam, redirectURI.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
case AccessMethodTerminal:
|
||||
// Return an error.
|
||||
// We don't support login redirects for the terminal since it's a
|
||||
// WebSocket endpoint and redirects won't work. The token must be
|
||||
// specified as a query parameter.
|
||||
if appReq.AccessMethod == AccessMethodTerminal {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
appBaseURL, err := issueReq.AppBaseURL()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "get app base URL")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// If the app is a path app and it's on the same host as the dashboard
|
||||
// access URL, then we need to redirect to login using the standard
|
||||
// login redirect function.
|
||||
if appReq.AccessMethod == AccessMethodPath && appBaseURL.Host == p.DashboardURL.Host {
|
||||
httpmw.RedirectToLogin(rw, r, p.DashboardURL, httpmw.SignedOutErrorMessage)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Otherwise, we need to redirect to the app auth endpoint, which will
|
||||
// redirect back to the app (with an encrypted API key) after the user
|
||||
// has logged in.
|
||||
//
|
||||
// TODO: We should just make this a "BrowserURL" field on the issue struct. Then
|
||||
// we can remove this logic and just defer to that. It can be set closer to the
|
||||
// actual initial request that makes the IssueTokenRequest. Eg the external moon.
|
||||
// This would replace RawQuery and AppPath fields.
|
||||
redirectURI := *appBaseURL
|
||||
if dbReq.AppURL != nil {
|
||||
// Just use the user's current path and query if set.
|
||||
if issueReq.AppPath != "" {
|
||||
redirectURI.Path = path.Join(redirectURI.Path, issueReq.AppPath)
|
||||
} else if !strings.HasSuffix(redirectURI.Path, "/") {
|
||||
redirectURI.Path += "/"
|
||||
}
|
||||
q := issueReq.AppQuery
|
||||
if q != "" && dbReq.AppURL.RawQuery != "" {
|
||||
q = dbReq.AppURL.RawQuery
|
||||
}
|
||||
redirectURI.RawQuery = q
|
||||
}
|
||||
|
||||
// This endpoint accepts redirect URIs from the primary app wildcard
|
||||
// host, proxy access URLs and proxy wildcard app hosts. It does not
|
||||
// accept redirect URIs from the primary access URL or any other host.
|
||||
u := *p.DashboardURL
|
||||
u.Path = "/api/v2/applications/auth-redirect"
|
||||
q := u.Query()
|
||||
q.Add(RedirectURIQueryParam, redirectURI.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Check that the agent is online.
|
||||
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
|
||||
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
|
||||
WriteWorkspaceAppOffline(p.Logger, p.AccessURL, rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
|
||||
WriteWorkspaceAppOffline(p.Logger, p.DashboardURL, rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// Check that the app is healthy.
|
||||
if dbReq.AppHealth != "" && dbReq.AppHealth != database.WorkspaceAppHealthDisabled && dbReq.AppHealth != database.WorkspaceAppHealthHealthy {
|
||||
WriteWorkspaceAppOffline(p.Logger, p.AccessURL, rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
|
||||
WriteWorkspaceAppOffline(p.Logger, p.DashboardURL, rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// As a sanity check, ensure the token we just made is valid for this
|
||||
// request.
|
||||
if !token.MatchesRequest(appReq) {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, nil, "fresh token does not match request")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, nil, "fresh token does not match request")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
@ -191,7 +211,7 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
token.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
tokenStr, err := p.SigningKey.SignToken(token)
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "generate token")
|
||||
WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "generate token")
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
|
@ -63,6 +63,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = true
|
||||
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
AppHostname: "*.test.coder.com",
|
||||
DeploymentValues: deploymentValues,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
@ -236,7 +237,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
// Try resolving the request without a token.
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
if !assert.True(t, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
@ -275,7 +283,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r = httptest.NewRequest("GET", "/app", nil)
|
||||
r.AddCookie(cookie)
|
||||
|
||||
secondToken, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
secondToken, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
// normalize expiry
|
||||
require.WithinDuration(t, token.Expiry, secondToken.Expiry, 2*time.Second)
|
||||
@ -304,7 +319,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
_ = w.Body.Close()
|
||||
if app == appNameOwner {
|
||||
@ -336,7 +358,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
t.Log("app", app)
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
if app != appNamePublic {
|
||||
require.False(t, ok)
|
||||
@ -367,7 +396,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@ -441,7 +477,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
w := rw.Result()
|
||||
if !assert.Equal(t, c.ok, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
@ -505,7 +548,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
|
||||
// Even though the token is invalid, we should still perform request
|
||||
// resolution without failure since we'll just ignore the bad token.
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, token)
|
||||
require.Equal(t, appNameOwner, token.AppSlugOrPort)
|
||||
@ -539,7 +589,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@ -560,7 +617,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
|
||||
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
|
||||
@ -579,7 +643,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, req.AccessMethod, token.AccessMethod)
|
||||
require.Equal(t, req.BasePath, token.BasePath)
|
||||
@ -606,7 +677,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@ -626,7 +704,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
})
|
||||
@ -645,15 +730,24 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/some-path", nil)
|
||||
// Should not be used as the hostname in the redirect URI.
|
||||
r.Host = "app.com"
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
AppPath: "/some-path",
|
||||
})
|
||||
require.False(t, ok)
|
||||
require.Nil(t, token)
|
||||
|
||||
w := rw.Result()
|
||||
defer w.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, w.StatusCode)
|
||||
require.Equal(t, http.StatusSeeOther, w.StatusCode)
|
||||
|
||||
loc, err := w.Location()
|
||||
require.NoError(t, err)
|
||||
@ -666,8 +760,11 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
redirectURI, err := url.Parse(redirectURIStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
appHost := fmt.Sprintf("%s--%s--%s--%s", req.AppSlugOrPort, req.AgentNameOrID, req.WorkspaceNameOrID, req.UsernameOrID)
|
||||
host := strings.Replace(api.AppHostname, "*", appHost, 1)
|
||||
|
||||
require.Equal(t, "http", redirectURI.Scheme)
|
||||
require.Equal(t, "app.com", redirectURI.Host)
|
||||
require.Equal(t, host, redirectURI.Host)
|
||||
require.Equal(t, "/some-path", redirectURI.Path)
|
||||
})
|
||||
|
||||
@ -687,7 +784,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok, "request succeeded even though agent is not connected")
|
||||
require.Nil(t, token)
|
||||
|
||||
@ -741,7 +845,14 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
|
||||
token, ok := workspaceapps.ResolveRequest(rw, r, workspaceapps.ResolveRequestOptions{
|
||||
Logger: api.Logger,
|
||||
SignedTokenProvider: api.WorkspaceAppsProvider,
|
||||
DashboardURL: api.AccessURL,
|
||||
PathAppBaseURL: api.AccessURL,
|
||||
AppHostname: api.AppHostname,
|
||||
AppRequest: req,
|
||||
})
|
||||
require.False(t, ok, "request succeeded even though app is unhealthy")
|
||||
require.Nil(t, token)
|
||||
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
@ -19,24 +20,50 @@ const (
|
||||
RedirectURIQueryParam = "redirect_uri"
|
||||
)
|
||||
|
||||
// ResolveRequest calls SignedTokenProvider to use an existing signed app token in the
|
||||
// request or issue a new one. If it returns a newly minted token, it sets the
|
||||
// cookie for you.
|
||||
func ResolveRequest(log slog.Logger, dashboardURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
|
||||
appReq = appReq.Normalize()
|
||||
type ResolveRequestOptions struct {
|
||||
Logger slog.Logger
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
|
||||
DashboardURL *url.URL
|
||||
PathAppBaseURL *url.URL
|
||||
AppHostname string
|
||||
|
||||
AppRequest Request
|
||||
// TODO: Replace these 2 fields with a "BrowserURL" field which is used for
|
||||
// redirecting the user back to their initial request after authenticating.
|
||||
// AppPath is the path under the app that was hit.
|
||||
AppPath string
|
||||
// AppQuery is the raw query of the request.
|
||||
AppQuery string
|
||||
}
|
||||
|
||||
func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequestOptions) (*SignedToken, bool) {
|
||||
appReq := opts.AppRequest.Normalize()
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(log, dashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
// 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
|
||||
// already be validated before they are put into the struct.
|
||||
WriteWorkspaceApp500(opts.Logger, opts.DashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
token, ok := p.TokenFromRequest(r)
|
||||
token, ok := opts.SignedTokenProvider.FromRequest(r)
|
||||
if ok && token.MatchesRequest(appReq) {
|
||||
// The request has a valid signed app token and it matches the request.
|
||||
return token, true
|
||||
}
|
||||
|
||||
token, tokenStr, ok := p.CreateToken(r.Context(), rw, r, appReq)
|
||||
issueReq := IssueTokenRequest{
|
||||
AppRequest: appReq,
|
||||
PathAppBaseURL: opts.PathAppBaseURL.String(),
|
||||
AppHostname: opts.AppHostname,
|
||||
SessionToken: httpmw.APITokenFromRequest(r),
|
||||
AppPath: opts.AppPath,
|
||||
AppQuery: opts.AppQuery,
|
||||
}
|
||||
|
||||
token, tokenStr, ok := opts.SignedTokenProvider.Issue(r.Context(), rw, r, issueReq)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
@ -56,17 +83,17 @@ func ResolveRequest(log slog.Logger, dashboardURL *url.URL, p SignedTokenProvide
|
||||
|
||||
// SignedTokenProvider provides signed workspace app tokens (aka. app tickets).
|
||||
type SignedTokenProvider interface {
|
||||
// TokenFromRequest returns a parsed token from the request. If the request
|
||||
// does not contain a signed app token or is is invalid (expired, invalid
|
||||
// FromRequest returns a parsed token from the request. If the request does
|
||||
// not contain a signed app token or is is invalid (expired, invalid
|
||||
// signature, etc.), it returns false.
|
||||
TokenFromRequest(r *http.Request) (*SignedToken, bool)
|
||||
// CreateToken mints a new token for the given app request. It uses the
|
||||
// long-lived session token in the HTTP request to authenticate and
|
||||
// authorize the client for the given workspace app. The token is returned
|
||||
// in struct and string form. The string form should be written as a cookie.
|
||||
FromRequest(r *http.Request) (*SignedToken, bool)
|
||||
// Issue mints a new token for the given app request. It uses the long-lived
|
||||
// session token in the HTTP request to authenticate and authorize the
|
||||
// client for the given workspace app. The token is returned in struct and
|
||||
// string form. The string form should be written as a cookie.
|
||||
//
|
||||
// If the request is invalid or the user is not authorized to access the
|
||||
// app, false is returned. An error page is written to the response writer
|
||||
// in this case.
|
||||
CreateToken(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, string, bool)
|
||||
Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq IssueTokenRequest) (*SignedToken, string, bool)
|
||||
}
|
||||
|
@ -78,14 +78,22 @@ type Server struct {
|
||||
Hostname string
|
||||
// HostnameRegex contains the regex version of Hostname as generated by
|
||||
// httpapi.CompileHostnamePattern(). It MUST be set if Hostname is set.
|
||||
HostnameRegex *regexp.Regexp
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
HostnameRegex *regexp.Regexp
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
WorkspaceConnCache *wsconncache.Cache
|
||||
AppSecurityKey SecurityKey
|
||||
|
||||
// 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
|
||||
// by a malicious workspace app.
|
||||
//
|
||||
// Subdomain apps are safer with their cookies scoped to the subdomain, and XSS
|
||||
// calls to the dashboard are not possible due to CORs.
|
||||
DisablePathApps bool
|
||||
SecureAuthCookie bool
|
||||
|
||||
websocketWaitMutex sync.Mutex
|
||||
websocketWaitGroup sync.WaitGroup
|
||||
}
|
||||
@ -117,10 +125,109 @@ func (s *Server) Attach(r chi.Router) {
|
||||
r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY)
|
||||
}
|
||||
|
||||
// handleAPIKeySmuggling is called by the proxy path and subdomain handlers to
|
||||
// process any "smuggled" API keys in the query parameters.
|
||||
//
|
||||
// If a smuggled key is found, it is decrypted and the cookie is set, and the
|
||||
// user is redirected to strip the query parameter.
|
||||
func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request, accessMethod AccessMethod) bool {
|
||||
ctx := r.Context()
|
||||
|
||||
encryptedAPIKey := r.URL.Query().Get(SubdomainProxyAPIKeyParam)
|
||||
if encryptedAPIKey == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// API key smuggling is not permitted for path apps on the primary access
|
||||
// URL. The user is already covered by their full session token.
|
||||
if accessMethod == AccessMethodPath && s.AccessURL.Host == s.DashboardURL.Host {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Workspace app API key smuggling is not permitted on the primary access URL. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove the query
|
||||
// parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Exchange the encoded API key for a real one.
|
||||
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "could not decrypt smuggled workspace app API key", slog.Error(err))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove the query
|
||||
// parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the cookie. For subdomain apps, we set the cookie on the whole
|
||||
// wildcard so users don't need to re-auth for every subdomain app they
|
||||
// access. For path apps (only on proxies, see above) we just set it on the
|
||||
// current domain.
|
||||
domain := "" // use the current domain
|
||||
if accessMethod == AccessMethodSubdomain {
|
||||
hostSplit := strings.SplitN(s.Hostname, ".", 2)
|
||||
if len(hostSplit) != 2 {
|
||||
// This should be impossible as we verify the app hostname on
|
||||
// startup, but we'll check anyways.
|
||||
s.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", s.Hostname))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the cookie for all subdomains of s.Hostname.
|
||||
domain = "." + hostSplit[1]
|
||||
}
|
||||
|
||||
// We don't set an expiration because the key in the database already has an
|
||||
// expiration, and expired tokens don't affect the user experience (they get
|
||||
// auto-redirected to re-smuggle the API key).
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: codersdk.DevURLSessionTokenCookie,
|
||||
Value: token,
|
||||
Domain: domain,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.SecureAuthCookie,
|
||||
})
|
||||
|
||||
// Strip the query parameter.
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query()
|
||||
q.Del(SubdomainProxyAPIKeyParam)
|
||||
rawQuery := q.Encode()
|
||||
if rawQuery != "" {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
http.Redirect(rw, r, path, http.StatusSeeOther)
|
||||
return false
|
||||
}
|
||||
|
||||
// workspaceAppsProxyPath proxies requests to a workspace application
|
||||
// through a relative URL path.
|
||||
func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
|
||||
if s.DeploymentValues.DisablePathApps.Value() {
|
||||
if s.DisablePathApps {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusUnauthorized,
|
||||
Title: "Unauthorized",
|
||||
@ -144,6 +251,10 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.handleAPIKeySmuggling(rw, r, AccessMethodPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the real path that was hit. The * URL parameter in Chi will not
|
||||
// include the leading slash if it was present, so we need to add it back.
|
||||
chiPath := chi.URLParam(r, "*")
|
||||
@ -154,14 +265,23 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
// ResolveRequest will only return a new signed token if the actor has the RBAC
|
||||
// permissions to connect to a workspace.
|
||||
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodPath,
|
||||
BasePath: basePath,
|
||||
UsernameOrID: chi.URLParam(r, "user"),
|
||||
WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
|
||||
// We don't support port proxying on paths. The ResolveRequest method
|
||||
// won't allow port proxying on path-based apps if the app is a number.
|
||||
AppSlugOrPort: chi.URLParam(r, "workspaceapp"),
|
||||
token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
AppHostname: s.Hostname,
|
||||
AppRequest: Request{
|
||||
AccessMethod: AccessMethodPath,
|
||||
BasePath: basePath,
|
||||
UsernameOrID: chi.URLParam(r, "user"),
|
||||
WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
|
||||
// We don't support port proxying on paths. The ResolveRequest method
|
||||
// won't allow port proxying on path-based apps if the app is a number.
|
||||
AppSlugOrPort: chi.URLParam(r, "workspaceapp"),
|
||||
},
|
||||
AppPath: chiPath,
|
||||
AppQuery: r.URL.RawQuery,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
@ -170,7 +290,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
s.proxyWorkspaceApp(rw, r, *token, chiPath)
|
||||
}
|
||||
|
||||
// SubdomainAppMW handles subdomain-based application proxy requests (aka.
|
||||
// HandleSubdomain handles subdomain-based application proxy requests (aka.
|
||||
// DevURLs in Coder V1).
|
||||
//
|
||||
// There are a lot of paths here:
|
||||
@ -205,7 +325,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
// 6. We finally verify that the "rest" matches api.Hostname for security
|
||||
// purposes regarding re-authentication and application proxy session
|
||||
// tokens.
|
||||
func (s *Server) SubdomainAppMW(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
@ -241,50 +361,26 @@ func (s *Server) SubdomainAppMW(middlewares ...func(http.Handler) http.Handler)
|
||||
return
|
||||
}
|
||||
|
||||
// If the request has the special query param then we need to set a
|
||||
// cookie and strip that query parameter.
|
||||
if encryptedAPIKey := r.URL.Query().Get(SubdomainProxyAPIKeyParam); encryptedAPIKey != "" {
|
||||
// Exchange the encoded API key for a real one.
|
||||
token, err := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "could not decrypt API key", slog.Error(err))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
Title: "Bad Request",
|
||||
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
|
||||
// Retry is disabled because the user needs to remove
|
||||
// the query parameter before they try again.
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.setWorkspaceAppCookie(rw, r, token)
|
||||
|
||||
// Strip the query parameter.
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query()
|
||||
q.Del(SubdomainProxyAPIKeyParam)
|
||||
rawQuery := q.Encode()
|
||||
if rawQuery != "" {
|
||||
path += "?" + q.Encode()
|
||||
}
|
||||
|
||||
http.Redirect(rw, r, path, http.StatusTemporaryRedirect)
|
||||
if !s.handleAPIKeySmuggling(rw, r, AccessMethodSubdomain) {
|
||||
return
|
||||
}
|
||||
|
||||
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: app.Username,
|
||||
WorkspaceNameOrID: app.WorkspaceName,
|
||||
AgentNameOrID: app.AgentName,
|
||||
AppSlugOrPort: app.AppSlugOrPort,
|
||||
token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
AppHostname: s.Hostname,
|
||||
AppRequest: Request{
|
||||
AccessMethod: AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: app.Username,
|
||||
WorkspaceNameOrID: app.WorkspaceName,
|
||||
AgentNameOrID: app.AgentName,
|
||||
AppSlugOrPort: app.AppSlugOrPort,
|
||||
},
|
||||
AppPath: r.URL.Path,
|
||||
AppQuery: r.URL.RawQuery,
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
@ -333,7 +429,7 @@ func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next htt
|
||||
// Check if the request is part of the deprecated logout flow. If so, we
|
||||
// just redirect to the main access URL.
|
||||
if subdomain == appLogoutHostname {
|
||||
http.Redirect(rw, r, s.AccessURL.String(), http.StatusTemporaryRedirect)
|
||||
http.Redirect(rw, r, s.AccessURL.String(), http.StatusSeeOther)
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
@ -353,44 +449,6 @@ func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next htt
|
||||
return app, true
|
||||
}
|
||||
|
||||
// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
|
||||
// hostname cannot be parsed properly, a static error page is rendered and false
|
||||
// is returned.
|
||||
func (s *Server) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
|
||||
hostSplit := strings.SplitN(s.Hostname, ".", 2)
|
||||
if len(hostSplit) != 2 {
|
||||
// This should be impossible as we verify the app hostname on
|
||||
// startup, but we'll check anyways.
|
||||
s.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", s.Hostname))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the app cookie for all subdomains of s.Hostname. We don't set an
|
||||
// expiration because the key in the database already has an expiration, and
|
||||
// expired tokens don't affect the user experience (they get auto-redirected
|
||||
// to re-smuggle the API key).
|
||||
cookieHost := "." + hostSplit[1]
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: codersdk.DevURLSessionTokenCookie,
|
||||
Value: token,
|
||||
Domain: cookieHost,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.DeploymentValues.SecureAuthCookie.Value(),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string) {
|
||||
ctx := r.Context()
|
||||
|
||||
@ -525,10 +583,19 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
s.websocketWaitMutex.Unlock()
|
||||
defer s.websocketWaitGroup.Done()
|
||||
|
||||
appToken, ok := ResolveRequest(s.Logger, s.AccessURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodTerminal,
|
||||
BasePath: r.URL.Path,
|
||||
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
||||
appToken, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
AppHostname: s.Hostname,
|
||||
AppRequest: Request{
|
||||
AccessMethod: AccessMethodTerminal,
|
||||
BasePath: r.URL.Path,
|
||||
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
||||
},
|
||||
AppPath: r.URL.Path,
|
||||
AppQuery: "",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
@ -565,12 +632,14 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
agentConn, release, err := s.WorkspaceConnCache.Acquire(appToken.AgentID)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "dial workspace agent", slog.Error(err))
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"))
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "dial reconnecting pty server in workspace agent", slog.Error(err))
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
|
||||
return
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@ -25,6 +26,50 @@ const (
|
||||
AccessMethodTerminal AccessMethod = "terminal"
|
||||
)
|
||||
|
||||
type IssueTokenRequest struct {
|
||||
AppRequest Request `json:"app_request"`
|
||||
// PathAppBaseURL is required.
|
||||
PathAppBaseURL string `json:"path_app_base_url"`
|
||||
// AppHostname is the optional hostname for subdomain apps on the external
|
||||
// proxy. It must start with an asterisk.
|
||||
AppHostname string `json:"app_hostname"`
|
||||
// AppPath is the path of the user underneath the app base path.
|
||||
AppPath string `json:"app_path"`
|
||||
// AppQuery is the query parameters the user provided in the app request.
|
||||
AppQuery string `json:"app_query"`
|
||||
// SessionToken is the session token provided by the user.
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
|
||||
// AppBaseURL returns the base URL of this specific app request. An error is
|
||||
// returned if a subdomain app hostname is not provided but the app is a
|
||||
// subdomain app.
|
||||
func (r IssueTokenRequest) AppBaseURL() (*url.URL, error) {
|
||||
u, err := url.Parse(r.PathAppBaseURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse path app base URL: %w", err)
|
||||
}
|
||||
|
||||
switch r.AppRequest.AccessMethod {
|
||||
case AccessMethodPath, AccessMethodTerminal:
|
||||
u.Path = r.AppRequest.BasePath
|
||||
if !strings.HasSuffix(u.Path, "/") {
|
||||
u.Path += "/"
|
||||
}
|
||||
return u, nil
|
||||
case AccessMethodSubdomain:
|
||||
if r.AppHostname == "" {
|
||||
return nil, xerrors.New("subdomain app hostname is required to generate subdomain app URL")
|
||||
}
|
||||
appHost := fmt.Sprintf("%s--%s--%s--%s", r.AppRequest.AppSlugOrPort, r.AppRequest.AgentNameOrID, r.AppRequest.WorkspaceNameOrID, r.AppRequest.UsernameOrID)
|
||||
u.Host = strings.Replace(r.AppHostname, "*", appHost, 1)
|
||||
u.Path = r.AppRequest.BasePath
|
||||
return u, nil
|
||||
default:
|
||||
return nil, xerrors.Errorf("invalid access method: %q", r.AppRequest.AccessMethod)
|
||||
}
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
AccessMethod AccessMethod `json:"access_method"`
|
||||
// BasePath of the app. For path apps, this is the path prefix in the router
|
||||
@ -128,7 +173,7 @@ type databaseRequest struct {
|
||||
|
||||
// AppURL is the resolved URL to the workspace app. This is only set for non
|
||||
// terminal requests.
|
||||
AppURL string
|
||||
AppURL *url.URL
|
||||
// AppHealth is the health of the app. For terminal requests, this is always
|
||||
// database.WorkspaceAppHealthHealthy.
|
||||
AppHealth database.WorkspaceAppHealth
|
||||
@ -290,12 +335,17 @@ func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseR
|
||||
}
|
||||
}
|
||||
|
||||
appURLParsed, err := url.Parse(appURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse app URL %q: %w", appURL, err)
|
||||
}
|
||||
|
||||
return &databaseRequest{
|
||||
Request: r,
|
||||
User: user,
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
AppURL: appURL,
|
||||
AppURL: appURLParsed,
|
||||
AppHealth: appHealth,
|
||||
AppSharingLevel: appSharingLevel,
|
||||
}, nil
|
||||
@ -348,7 +398,7 @@ func (r Request) getDatabaseTerminal(ctx context.Context, db database.Store) (*d
|
||||
User: user,
|
||||
Workspace: workspace,
|
||||
Agent: agent,
|
||||
AppURL: "",
|
||||
AppURL: nil,
|
||||
AppHealth: database.WorkspaceAppHealthHealthy,
|
||||
AppSharingLevel: database.AppSharingLevelOwner,
|
||||
}, nil
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
@ -11,6 +12,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -217,3 +219,23 @@ func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
|
||||
|
||||
return payload.APIKey, nil
|
||||
}
|
||||
|
||||
func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) {
|
||||
// Get the existing token from the request.
|
||||
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
||||
if err == nil {
|
||||
token, err := key.VerifySignedToken(tokenCookie.Value)
|
||||
if err == nil {
|
||||
req := token.Request.Normalize()
|
||||
err := req.Validate()
|
||||
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
|
||||
// the request.
|
||||
return &token, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
Reference in New Issue
Block a user