chore: add workspace proxies to the backend (#7032)

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Steven Masley
2023-04-17 14:57:21 -05:00
committed by GitHub
parent dc5e16ae22
commit 658246d5f2
61 changed files with 3641 additions and 757 deletions
+126 -5
View File
@@ -5000,7 +5000,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"Templates"
"Enterprise"
],
"summary": "Create workspace proxy",
"operationId": "create-workspace-proxy",
@@ -5025,6 +5025,48 @@ const docTemplate = `{
}
}
},
"/workspaceproxies/me/issue-signed-app-token": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Issue signed workspace app token",
"operationId": "issue-signed-workspace-app-token",
"parameters": [
{
"description": "Issue signed app token request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/workspaceapps.IssueTokenRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/wsproxysdk.IssueSignedAppTokenResponse"
}
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/workspaces": {
"get": {
"security": [
@@ -6321,6 +6363,10 @@ const docTemplate = `{
"codersdk.BuildInfoResponse": {
"type": "object",
"properties": {
"dashboard_url": {
"description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.",
"type": "string"
},
"external_url": {
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
"type": "string"
@@ -6328,6 +6374,9 @@ const docTemplate = `{
"version": {
"description": "Version returns the semantic version of the build.",
"type": "string"
},
"workspace_proxy": {
"type": "boolean"
}
}
},
@@ -9514,10 +9563,6 @@ const docTemplate = `{
"name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"updated_at": {
"type": "string",
"format": "date-time"
@@ -10054,6 +10099,82 @@ const docTemplate = `{
},
"url.Userinfo": {
"type": "object"
},
"workspaceapps.AccessMethod": {
"type": "string",
"enum": [
"path",
"subdomain",
"terminal"
],
"x-enum-varnames": [
"AccessMethodPath",
"AccessMethodSubdomain",
"AccessMethodTerminal"
]
},
"workspaceapps.IssueTokenRequest": {
"type": "object",
"properties": {
"app_hostname": {
"description": "AppHostname is the optional hostname for subdomain apps on the external\nproxy. It must start with an asterisk.",
"type": "string"
},
"app_path": {
"description": "AppPath is the path of the user underneath the app base path.",
"type": "string"
},
"app_query": {
"description": "AppQuery is the query parameters the user provided in the app request.",
"type": "string"
},
"app_request": {
"$ref": "#/definitions/workspaceapps.Request"
},
"path_app_base_url": {
"description": "PathAppBaseURL is required.",
"type": "string"
},
"session_token": {
"description": "SessionToken is the session token provided by the user.",
"type": "string"
}
}
},
"workspaceapps.Request": {
"type": "object",
"properties": {
"access_method": {
"$ref": "#/definitions/workspaceapps.AccessMethod"
},
"agent_name_or_id": {
"description": "AgentNameOrID is not required if the workspace has only one agent.",
"type": "string"
},
"app_slug_or_port": {
"type": "string"
},
"base_path": {
"description": "BasePath of the app. For path apps, this is the path prefix in the router\nfor this particular app. For subdomain apps, this should be \"/\". This is\nused for setting the cookie path.",
"type": "string"
},
"username_or_id": {
"description": "For the following fields, if the AccessMethod is AccessMethodTerminal,\nthen only AgentNameOrID may be set and it must be a UUID. The other\nfields must be left blank.",
"type": "string"
},
"workspace_name_or_id": {
"type": "string"
}
}
},
"wsproxysdk.IssueSignedAppTokenResponse": {
"type": "object",
"properties": {
"signed_token_str": {
"description": "SignedTokenStr should be set as a cookie on the response.",
"type": "string"
}
}
}
},
"securityDefinitions": {
+116 -5
View File
@@ -4399,7 +4399,7 @@
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Templates"],
"tags": ["Enterprise"],
"summary": "Create workspace proxy",
"operationId": "create-workspace-proxy",
"parameters": [
@@ -4423,6 +4423,42 @@
}
}
},
"/workspaceproxies/me/issue-signed-app-token": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Issue signed workspace app token",
"operationId": "issue-signed-workspace-app-token",
"parameters": [
{
"description": "Issue signed app token request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/workspaceapps.IssueTokenRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/wsproxysdk.IssueSignedAppTokenResponse"
}
}
},
"x-apidocgen": {
"skip": true
}
}
},
"/workspaces": {
"get": {
"security": [
@@ -5639,6 +5675,10 @@
"codersdk.BuildInfoResponse": {
"type": "object",
"properties": {
"dashboard_url": {
"description": "DashboardURL is the URL to hit the deployment's dashboard.\nFor external workspace proxies, this is the coderd they are connected\nto.",
"type": "string"
},
"external_url": {
"description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.",
"type": "string"
@@ -5646,6 +5686,9 @@
"version": {
"description": "Version returns the semantic version of the build.",
"type": "string"
},
"workspace_proxy": {
"type": "boolean"
}
}
},
@@ -8602,10 +8645,6 @@
"name": {
"type": "string"
},
"organization_id": {
"type": "string",
"format": "uuid"
},
"updated_at": {
"type": "string",
"format": "date-time"
@@ -9123,6 +9162,78 @@
},
"url.Userinfo": {
"type": "object"
},
"workspaceapps.AccessMethod": {
"type": "string",
"enum": ["path", "subdomain", "terminal"],
"x-enum-varnames": [
"AccessMethodPath",
"AccessMethodSubdomain",
"AccessMethodTerminal"
]
},
"workspaceapps.IssueTokenRequest": {
"type": "object",
"properties": {
"app_hostname": {
"description": "AppHostname is the optional hostname for subdomain apps on the external\nproxy. It must start with an asterisk.",
"type": "string"
},
"app_path": {
"description": "AppPath is the path of the user underneath the app base path.",
"type": "string"
},
"app_query": {
"description": "AppQuery is the query parameters the user provided in the app request.",
"type": "string"
},
"app_request": {
"$ref": "#/definitions/workspaceapps.Request"
},
"path_app_base_url": {
"description": "PathAppBaseURL is required.",
"type": "string"
},
"session_token": {
"description": "SessionToken is the session token provided by the user.",
"type": "string"
}
}
},
"workspaceapps.Request": {
"type": "object",
"properties": {
"access_method": {
"$ref": "#/definitions/workspaceapps.AccessMethod"
},
"agent_name_or_id": {
"description": "AgentNameOrID is not required if the workspace has only one agent.",
"type": "string"
},
"app_slug_or_port": {
"type": "string"
},
"base_path": {
"description": "BasePath of the app. For path apps, this is the path prefix in the router\nfor this particular app. For subdomain apps, this should be \"/\". This is\nused for setting the cookie path.",
"type": "string"
},
"username_or_id": {
"description": "For the following fields, if the AccessMethod is AccessMethodTerminal,\nthen only AgentNameOrID may be set and it must be a UUID. The other\nfields must be left blank.",
"type": "string"
},
"workspace_name_or_id": {
"type": "string"
}
}
},
"wsproxysdk.IssueSignedAppTokenResponse": {
"type": "object",
"properties": {
"signed_token_str": {
"description": "SignedTokenStr should be set as a cookie on the response.",
"type": "string"
}
}
}
},
"securityDefinitions": {
+4 -4
View File
@@ -25,7 +25,7 @@ func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action
h.Logger.Error(r.Context(), "filter failed",
slog.Error(err),
slog.F("user_id", roles.Actor.ID),
slog.F("username", roles.Username),
slog.F("username", roles.ActorName),
slog.F("roles", roles.Actor.SafeRoleNames()),
slog.F("scope", roles.Actor.SafeScopeName()),
slog.F("route", r.URL.Path),
@@ -77,8 +77,8 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r
// in the early days
logger.Warn(r.Context(), "unauthorized",
slog.F("roles", roles.Actor.SafeRoleNames()),
slog.F("user_id", roles.Actor.ID),
slog.F("username", roles.Username),
slog.F("actor_id", roles.Actor.ID),
slog.F("actor_name", roles.ActorName),
slog.F("scope", roles.Actor.SafeScopeName()),
slog.F("route", r.URL.Path),
slog.F("action", action),
@@ -129,7 +129,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) {
api.Logger.Debug(ctx, "check-auth",
slog.F("my_id", httpmw.APIKey(r).UserID),
slog.F("got_id", auth.Actor.ID),
slog.F("name", auth.Username),
slog.F("name", auth.ActorName),
slog.F("roles", auth.Actor.SafeRoleNames()),
slog.F("scope", auth.Actor.SafeScopeName()),
)
+29 -12
View File
@@ -36,9 +36,9 @@ import (
"tailscale.com/util/singleflight"
"cdr.dev/slog"
"github.com/coder/coder/buildinfo"
// Used to serve the Swagger endpoint
"github.com/coder/coder/buildinfo"
// Used for swagger docs.
_ "github.com/coder/coder/coderd/apidoc"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/awsidentity"
@@ -290,8 +290,8 @@ func New(options *Options) *API {
OIDC: options.OIDCConfig,
}
r := chi.NewRouter()
ctx, cancel := context.WithCancel(context.Background())
r := chi.NewRouter()
api := &API{
ctx: ctx,
cancel: cancel,
@@ -340,16 +340,18 @@ func New(options *Options) *API {
api.workspaceAppServer = &workspaceapps.Server{
Logger: options.Logger.Named("workspaceapps"),
DashboardURL: api.AccessURL,
AccessURL: api.AccessURL,
Hostname: api.AppHostname,
HostnameRegex: api.AppHostnameRegex,
DeploymentValues: options.DeploymentValues,
RealIPConfig: options.RealIPConfig,
DashboardURL: api.AccessURL,
AccessURL: api.AccessURL,
Hostname: api.AppHostname,
HostnameRegex: api.AppHostnameRegex,
RealIPConfig: options.RealIPConfig,
SignedTokenProvider: api.WorkspaceAppsProvider,
WorkspaceConnCache: api.workspaceAgentCache,
AppSecurityKey: options.AppSecurityKey,
DisablePathApps: options.DeploymentValues.DisablePathApps.Value(),
SecureAuthCookie: options.DeploymentValues.SecureAuthCookie.Value(),
}
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
@@ -367,6 +369,14 @@ func New(options *Options) *API {
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
Optional: false,
})
// Same as the first but it's optional.
apiKeyMiddlewareOptional := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: false,
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
Optional: true,
})
// API rate limit middleware. The counter is local and not shared between
// replicas or instances of this middleware.
@@ -389,7 +399,7 @@ func New(options *Options) *API {
//
// Workspace apps do their own auth and must be BEFORE the auth
// middleware.
api.workspaceAppServer.SubdomainAppMW(apiRateLimiter),
api.workspaceAppServer.HandleSubdomain(apiRateLimiter),
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -450,7 +460,7 @@ func New(options *Options) *API {
// All CSP errors will be logged
r.Post("/csp/reports", api.logReportCSPViolations)
r.Get("/buildinfo", buildInfo)
r.Get("/buildinfo", buildInfo(api.AccessURL))
r.Route("/deployment", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/config", api.deploymentValues)
@@ -661,7 +671,14 @@ func New(options *Options) *API {
})
r.Route("/{workspaceagent}", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
// Allow either API key or external workspace proxy auth and require it.
apiKeyMiddlewareOptional,
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: options.Database,
Optional: true,
}),
httpmw.RequireAPIKeyOrWorkspaceProxyAuth(),
httpmw.ExtractWorkspaceAgentParam(options.Database),
httpmw.ExtractWorkspaceParam(options.Database),
)
+1
View File
@@ -180,6 +180,7 @@ var (
rbac.ResourceUser.Type: {rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
rbac.ResourceUserData.Type: {rbac.ActionCreate, rbac.ActionUpdate},
rbac.ResourceWorkspace.Type: {rbac.ActionUpdate},
rbac.ResourceWorkspaceExecution.Type: {rbac.ActionCreate},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
+4
View File
@@ -1697,6 +1697,10 @@ func (q *querier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (data
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByID)(ctx, id)
}
func (q *querier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (database.WorkspaceProxy, error) {
return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByHostname)(ctx, hostname)
}
func (q *querier) InsertWorkspaceProxy(ctx context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
return insert(q.log, q.auth, rbac.ResourceWorkspaceProxy, q.db.InsertWorkspaceProxy)(ctx, arg)
}
+5 -5
View File
@@ -445,25 +445,25 @@ func (s *MethodTestSuite) TestWorkspaceProxy() {
}).Asserts(rbac.ResourceWorkspaceProxy, rbac.ActionCreate)
}))
s.Run("UpdateWorkspaceProxy", s.Subtest(func(db database.Store, check *expects) {
p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
check.Args(database.UpdateWorkspaceProxyParams{
ID: p.ID,
}).Asserts(p, rbac.ActionUpdate)
}))
s.Run("GetWorkspaceProxyByID", s.Subtest(func(db database.Store, check *expects) {
p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
check.Args(p.ID).Asserts(p, rbac.ActionRead).Returns(p)
}))
s.Run("UpdateWorkspaceProxyDeleted", s.Subtest(func(db database.Store, check *expects) {
p := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
p, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
check.Args(database.UpdateWorkspaceProxyDeletedParams{
ID: p.ID,
Deleted: true,
}).Asserts(p, rbac.ActionDelete)
}))
s.Run("GetWorkspaceProxies", s.Subtest(func(db database.Store, check *expects) {
p1 := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
p2 := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
p1, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
p2, _ := dbgen.WorkspaceProxy(s.T(), db, database.WorkspaceProxy{})
check.Args().Asserts(p1, rbac.ActionRead, p2, rbac.ActionRead).Returns(slice.New(p1, p2))
}))
}
+48 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"regexp"
"sort"
"strings"
"sync"
@@ -18,10 +19,13 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/util/slice"
)
var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`)
// FakeDatabase is helpful for knowing if the underlying db is an in memory fake
// database. This is only in the databasefake package, so will only be used
// by unit tests.
@@ -5093,6 +5097,40 @@ func (q *fakeQuerier) GetWorkspaceProxyByID(_ context.Context, id uuid.UUID) (da
return database.WorkspaceProxy{}, sql.ErrNoRows
}
func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
// Return zero rows if this is called with a non-sanitized hostname. The SQL
// version of this query does the same thing.
if !validProxyByHostnameRegex.MatchString(hostname) {
return database.WorkspaceProxy{}, sql.ErrNoRows
}
// This regex matches the SQL version.
accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(hostname) + `([:/]?.)*`)
for _, proxy := range q.workspaceProxies {
if proxy.Deleted {
continue
}
if accessURLRegex.MatchString(proxy.Url) {
return proxy, nil
}
// Compile the app hostname regex. This is slow sadly.
wildcardRegexp, err := httpapi.CompileHostnamePattern(proxy.WildcardHostname)
if err != nil {
return database.WorkspaceProxy{}, xerrors.Errorf("compile hostname pattern %q for proxy %q (%s): %w", proxy.WildcardHostname, proxy.Name, proxy.ID.String(), err)
}
if _, ok := httpapi.ExecuteHostnamePattern(wildcardRegexp, hostname); ok {
return proxy, nil
}
}
return database.WorkspaceProxy{}, sql.ErrNoRows
}
func (q *fakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.InsertWorkspaceProxyParams) (database.WorkspaceProxy, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
@@ -5104,14 +5142,16 @@ func (q *fakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.Inser
}
p := database.WorkspaceProxy{
ID: arg.ID,
Name: arg.Name,
Icon: arg.Icon,
Url: arg.Url,
WildcardHostname: arg.WildcardHostname,
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
Deleted: false,
ID: arg.ID,
Name: arg.Name,
DisplayName: arg.DisplayName,
Icon: arg.Icon,
Url: arg.Url,
WildcardHostname: arg.WildcardHostname,
TokenHashedSecret: arg.TokenHashedSecret,
CreatedAt: arg.CreatedAt,
UpdatedAt: arg.UpdatedAt,
Deleted: false,
}
q.workspaceProxies = append(q.workspaceProxies, p)
return p, nil
@@ -129,6 +129,96 @@ func TestUserOrder(t *testing.T) {
}
}
func TestProxyByHostname(t *testing.T) {
t.Parallel()
db := dbfake.New()
// Insert a bunch of different proxies.
proxies := []struct {
name string
accessURL string
wildcardHostname string
}{
{
name: "one",
accessURL: "https://one.coder.com",
wildcardHostname: "*.wildcard.one.coder.com",
},
{
name: "two",
accessURL: "https://two.coder.com",
wildcardHostname: "*--suffix.two.coder.com",
},
}
for _, p := range proxies {
dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
Name: p.name,
Url: p.accessURL,
WildcardHostname: p.wildcardHostname,
})
}
cases := []struct {
name string
testHostname string
matchProxyName string
}{
{
name: "NoMatch",
testHostname: "test.com",
matchProxyName: "",
},
{
name: "MatchAccessURL",
testHostname: "one.coder.com",
matchProxyName: "one",
},
{
name: "MatchWildcard",
testHostname: "something.wildcard.one.coder.com",
matchProxyName: "one",
},
{
name: "MatchSuffix",
testHostname: "something--suffix.two.coder.com",
matchProxyName: "two",
},
{
name: "ValidateHostname/1",
testHostname: ".*ne.coder.com",
matchProxyName: "",
},
{
name: "ValidateHostname/2",
testHostname: "https://one.coder.com",
matchProxyName: "",
},
{
name: "ValidateHostname/3",
testHostname: "one.coder.com:8080/hello",
matchProxyName: "",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
if c.matchProxyName == "" {
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, proxy)
} else {
require.NoError(t, err)
require.NotEmpty(t, proxy)
require.Equal(t, c.matchProxyName, proxy.Name)
}
})
}
}
func methods(rt reflect.Type) map[string]bool {
methods := make(map[string]bool)
for i := 0; i < rt.NumMethod(); i++ {
+16 -11
View File
@@ -338,19 +338,24 @@ func WorkspaceResourceMetadatums(t testing.TB, db database.Store, seed database.
return meta
}
func WorkspaceProxy(t testing.TB, db database.Store, orig database.WorkspaceProxy) database.WorkspaceProxy {
func WorkspaceProxy(t testing.TB, db database.Store, orig database.WorkspaceProxy) (database.WorkspaceProxy, string) {
secret, err := cryptorand.HexString(64)
require.NoError(t, err, "generate secret")
hashedSecret := sha256.Sum256([]byte(secret))
resource, err := db.InsertWorkspaceProxy(context.Background(), database.InsertWorkspaceProxyParams{
ID: takeFirst(orig.ID, uuid.New()),
Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)),
DisplayName: takeFirst(orig.DisplayName, namesgenerator.GetRandomName(1)),
Icon: takeFirst(orig.Icon, namesgenerator.GetRandomName(1)),
Url: takeFirst(orig.Url, fmt.Sprintf("https://%s.com", namesgenerator.GetRandomName(1))),
WildcardHostname: takeFirst(orig.WildcardHostname, fmt.Sprintf(".%s.com", namesgenerator.GetRandomName(1))),
CreatedAt: takeFirst(orig.CreatedAt, database.Now()),
UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()),
ID: takeFirst(orig.ID, uuid.New()),
Name: takeFirst(orig.Name, namesgenerator.GetRandomName(1)),
DisplayName: takeFirst(orig.DisplayName, namesgenerator.GetRandomName(1)),
Icon: takeFirst(orig.Icon, namesgenerator.GetRandomName(1)),
Url: takeFirst(orig.Url, fmt.Sprintf("https://%s.com", namesgenerator.GetRandomName(1))),
WildcardHostname: takeFirst(orig.WildcardHostname, fmt.Sprintf("*.%s.com", namesgenerator.GetRandomName(1))),
TokenHashedSecret: hashedSecret[:],
CreatedAt: takeFirst(orig.CreatedAt, database.Now()),
UpdatedAt: takeFirst(orig.UpdatedAt, database.Now()),
})
require.NoError(t, err, "insert app")
return resource
require.NoError(t, err, "insert proxy")
return resource, secret
}
func File(t testing.TB, db database.Store, orig database.File) database.File {
+2 -1
View File
@@ -78,7 +78,8 @@ func TestGenerator(t *testing.T) {
t.Run("WorkspaceProxy", func(t *testing.T) {
t.Parallel()
db := dbfake.New()
exp := dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
exp, secret := dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
require.Len(t, secret, 64)
require.Equal(t, exp, must(db.GetWorkspaceProxyByID(context.Background(), exp.ID)))
})
+8 -1
View File
@@ -647,13 +647,20 @@ CREATE TABLE workspace_proxies (
wildcard_hostname text NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
deleted boolean NOT NULL
deleted boolean NOT NULL,
token_hashed_secret bytea NOT NULL
);
COMMENT ON COLUMN workspace_proxies.icon IS 'Expects an emoji character. (/emojis/1f1fa-1f1f8.png)';
COMMENT ON COLUMN workspace_proxies.url IS 'Full url including scheme of the proxy api url: https://us.example.com';
COMMENT ON COLUMN workspace_proxies.wildcard_hostname IS 'Hostname with the wildcard for subdomain based app hosting: *.us.example.com';
COMMENT ON COLUMN workspace_proxies.deleted IS 'Boolean indicator of a deleted workspace proxy. Proxies are soft-deleted.';
COMMENT ON COLUMN workspace_proxies.token_hashed_secret IS 'Hashed secret is used to authenticate the workspace proxy using a session token.';
CREATE TABLE workspace_resource_metadata (
workspace_resource_id uuid NOT NULL,
key character varying(1024) NOT NULL,
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE workspace_proxies
DROP COLUMN token_hashed_secret;
COMMIT;
@@ -0,0 +1,22 @@
BEGIN;
-- It's difficult to generate tokens for existing proxies, so we'll just delete
-- them if they exist.
--
-- No one is using this feature yet as of writing this migration, so this is
-- fine.
DELETE FROM workspace_proxies;
ALTER TABLE workspace_proxies
ADD COLUMN token_hashed_secret bytea NOT NULL;
COMMENT ON COLUMN workspace_proxies.token_hashed_secret
IS 'Hashed secret is used to authenticate the workspace proxy using a session token.';
COMMENT ON COLUMN workspace_proxies.deleted
IS 'Boolean indicator of a deleted workspace proxy. Proxies are soft-deleted.';
COMMENT ON COLUMN workspace_proxies.icon
IS 'Expects an emoji character. (/emojis/1f1fa-1f1f8.png)';
COMMIT;
@@ -1,14 +0,0 @@
INSERT INTO workspace_proxies
(id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted)
VALUES
(
'cf8ede8c-ff47-441f-a738-d92e4e34a657',
'us',
'United States',
'/emojis/us.png',
'https://us.coder.com',
'*.us.coder.com',
'2023-03-30 12:00:00.000+02',
'2023-03-30 12:00:00.000+02',
false
);
@@ -0,0 +1,15 @@
INSERT INTO workspace_proxies
(id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret)
VALUES
(
'cf8ede8c-ff47-441f-a738-d92e4e34a657',
'us',
'United States',
'/emojis/us.png',
'https://us.coder.com',
'*.us.coder.com',
'2023-03-30 12:00:00.000+02',
'2023-03-30 12:00:00.000+02',
false,
'abc123'::bytea
);
+6 -2
View File
@@ -1674,14 +1674,18 @@ type WorkspaceProxy struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
// Expects an emoji character. (/emojis/1f1fa-1f1f8.png)
Icon string `db:"icon" json:"icon"`
// Full url including scheme of the proxy api url: https://us.example.com
Url string `db:"url" json:"url"`
// Hostname with the wildcard for subdomain based app hosting: *.us.example.com
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Deleted bool `db:"deleted" json:"deleted"`
// Boolean indicator of a deleted workspace proxy. Proxies are soft-deleted.
Deleted bool `db:"deleted" json:"deleted"`
// Hashed secret is used to authenticate the workspace proxy using a session token.
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
}
type WorkspaceResource struct {
+8
View File
@@ -149,6 +149,14 @@ type sqlcQuerier interface {
GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error)
GetWorkspaceByWorkspaceAppID(ctx context.Context, workspaceAppID uuid.UUID) (Workspace, error)
GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error)
// Finds a workspace proxy that has an access URL or app hostname that matches
// the provided hostname. This is to check if a hostname matches any workspace
// proxy.
//
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
// this query. The scheme, port and path should be stripped.
//
GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error)
GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)
GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error)
+96
View File
@@ -4,6 +4,7 @@ package database_test
import (
"context"
"database/sql"
"testing"
"time"
@@ -127,3 +128,98 @@ func TestInsertWorkspaceAgentStartupLogs(t *testing.T) {
})
require.True(t, database.IsStartupLogsLimitError(err))
}
func TestProxyByHostname(t *testing.T) {
t.Parallel()
if testing.Short() {
t.SkipNow()
}
sqlDB := testSQLDB(t)
err := migrations.Up(sqlDB)
require.NoError(t, err)
db := database.New(sqlDB)
// Insert a bunch of different proxies.
proxies := []struct {
name string
accessURL string
wildcardHostname string
}{
{
name: "one",
accessURL: "https://one.coder.com",
wildcardHostname: "*.wildcard.one.coder.com",
},
{
name: "two",
accessURL: "https://two.coder.com",
wildcardHostname: "*--suffix.two.coder.com",
},
}
for _, p := range proxies {
dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
Name: p.name,
Url: p.accessURL,
WildcardHostname: p.wildcardHostname,
})
}
cases := []struct {
name string
testHostname string
matchProxyName string
}{
{
name: "NoMatch",
testHostname: "test.com",
matchProxyName: "",
},
{
name: "MatchAccessURL",
testHostname: "one.coder.com",
matchProxyName: "one",
},
{
name: "MatchWildcard",
testHostname: "something.wildcard.one.coder.com",
matchProxyName: "one",
},
{
name: "MatchSuffix",
testHostname: "something--suffix.two.coder.com",
matchProxyName: "two",
},
{
name: "ValidateHostname/1",
testHostname: ".*ne.coder.com",
matchProxyName: "",
},
{
name: "ValidateHostname/2",
testHostname: "https://one.coder.com",
matchProxyName: "",
},
{
name: "ValidateHostname/3",
testHostname: "one.coder.com:8080/hello",
matchProxyName: "",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname)
if c.matchProxyName == "" {
require.ErrorIs(t, err, sql.ErrNoRows)
require.Empty(t, proxy)
} else {
require.NoError(t, err)
require.NotEmpty(t, proxy)
require.Equal(t, c.matchProxyName, proxy.Name)
}
})
}
}
+69 -12
View File
@@ -2817,7 +2817,7 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a
const getWorkspaceProxies = `-- name: GetWorkspaceProxies :many
SELECT
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
FROM
workspace_proxies
WHERE
@@ -2843,6 +2843,7 @@ func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.TokenHashedSecret,
); err != nil {
return nil, err
}
@@ -2857,9 +2858,59 @@ func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy,
return items, nil
}
const getWorkspaceProxyByHostname = `-- name: GetWorkspaceProxyByHostname :one
SELECT
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
FROM
workspace_proxies
WHERE
-- Validate that the @hostname has been sanitized and is not empty. This
-- doesn't prevent SQL injection (already prevented by using prepared
-- queries), but it does prevent carefully crafted hostnames from matching
-- when they shouldn't.
--
-- Periods don't need to be escaped because they're not special characters
-- in SQL matches unlike regular expressions.
$1 :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
deleted = false AND
-- Validate that the hostname matches either the wildcard hostname or the
-- access URL (ignoring scheme, port and path).
(
url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' OR
$1 :: text LIKE replace(wildcard_hostname, '*', '%')
)
LIMIT
1
`
// Finds a workspace proxy that has an access URL or app hostname that matches
// the provided hostname. This is to check if a hostname matches any workspace
// proxy.
//
// The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
// this query. The scheme, port and path should be stripped.
func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, hostname string) (WorkspaceProxy, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, hostname)
var i WorkspaceProxy
err := row.Scan(
&i.ID,
&i.Name,
&i.DisplayName,
&i.Icon,
&i.Url,
&i.WildcardHostname,
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.TokenHashedSecret,
)
return i, err
}
const getWorkspaceProxyByID = `-- name: GetWorkspaceProxyByID :one
SELECT
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
FROM
workspace_proxies
WHERE
@@ -2881,6 +2932,7 @@ func (q *sqlQuerier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (W
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.TokenHashedSecret,
)
return i, err
}
@@ -2894,23 +2946,25 @@ INSERT INTO
icon,
url,
wildcard_hostname,
token_hashed_secret,
created_at,
updated_at,
deleted
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
($1, $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
`
type InsertWorkspaceProxyParams struct {
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
Url string `db:"url" json:"url"`
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
Url string `db:"url" json:"url"`
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspaceProxyParams) (WorkspaceProxy, error) {
@@ -2921,6 +2975,7 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
arg.Icon,
arg.Url,
arg.WildcardHostname,
arg.TokenHashedSecret,
arg.CreatedAt,
arg.UpdatedAt,
)
@@ -2935,6 +2990,7 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.TokenHashedSecret,
)
return i, err
}
@@ -2951,7 +3007,7 @@ SET
updated_at = Now()
WHERE
id = $6
RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted
RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret
`
type UpdateWorkspaceProxyParams struct {
@@ -2983,6 +3039,7 @@ func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspa
&i.CreatedAt,
&i.UpdatedAt,
&i.Deleted,
&i.TokenHashedSecret,
)
return i, err
}
+34 -1
View File
@@ -7,12 +7,13 @@ INSERT INTO
icon,
url,
wildcard_hostname,
token_hashed_secret,
created_at,
updated_at,
deleted
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, false) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING *;
-- name: UpdateWorkspaceProxy :one
UPDATE
@@ -48,6 +49,38 @@ WHERE
LIMIT
1;
-- Finds a workspace proxy that has an access URL or app hostname that matches
-- the provided hostname. This is to check if a hostname matches any workspace
-- proxy.
--
-- The hostname must be sanitized to only contain [a-zA-Z0-9.-] before calling
-- this query. The scheme, port and path should be stripped.
--
-- name: GetWorkspaceProxyByHostname :one
SELECT
*
FROM
workspace_proxies
WHERE
-- Validate that the @hostname has been sanitized and is not empty. This
-- doesn't prevent SQL injection (already prevented by using prepared
-- queries), but it does prevent carefully crafted hostnames from matching
-- when they shouldn't.
--
-- Periods don't need to be escaped because they're not special characters
-- in SQL matches unlike regular expressions.
@hostname :: text SIMILAR TO '[a-zA-Z0-9.-]+' AND
deleted = false AND
-- Validate that the hostname matches either the wildcard hostname or the
-- access URL (ignoring scheme, port and path).
(
url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' OR
@hostname :: text LIKE replace(wildcard_hostname, '*', '%')
)
LIMIT
1;
-- name: GetWorkspaceProxies :many
SELECT
*
+10 -5
View File
@@ -2,6 +2,7 @@ package coderd
import (
"net/http"
"net/url"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/httpapi"
@@ -67,11 +68,15 @@ func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) {
// @Tags General
// @Success 200 {object} codersdk.BuildInfoResponse
// @Router /buildinfo [get]
func buildInfo(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
})
func buildInfo(accessURL *url.URL) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
DashboardURL: accessURL.String(),
WorkspaceProxy: false,
})
}
}
// @Summary SSH Config
+37
View File
@@ -0,0 +1,37 @@
package httpmw
import (
"net/http"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
// RequireAPIKeyOrWorkspaceProxyAuth is middleware that should be inserted after
// optional ExtractAPIKey and ExtractWorkspaceProxy middlewares to ensure one of
// the two authentication methods is provided.
//
// If both are provided, an error is returned to avoid misuse.
func RequireAPIKeyOrWorkspaceProxyAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, hasAPIKey := APIKeyOptional(r)
_, hasWorkspaceProxy := WorkspaceProxyOptional(r)
if hasAPIKey && hasWorkspaceProxy {
httpapi.Write(r.Context(), w, http.StatusBadRequest, codersdk.Response{
Message: "API key and external proxy authentication provided, but only one is allowed",
})
return
}
if !hasAPIKey && !hasWorkspaceProxy {
httpapi.Write(r.Context(), w, http.StatusUnauthorized, codersdk.Response{
Message: "API key or external proxy authentication required, but none provided",
})
return
}
next.ServeHTTP(w, r)
})
}
}
+143
View File
@@ -0,0 +1,143 @@
package httpmw_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/http/httputil"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbfake"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
func TestRequireAPIKeyOrWorkspaceProxyAuth(t *testing.T) {
t.Parallel()
t.Run("None", func(t *testing.T) {
t.Parallel()
r := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("should not have been called")
})).ServeHTTP(rw, r)
require.Equal(t, http.StatusUnauthorized, rw.Code)
})
t.Run("APIKey", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
user = dbgen.User(t, db, database.User{})
_, token = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
ExpiresAt: database.Now().AddDate(0, 0, 1),
})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(codersdk.SessionTokenHeader, token)
var called int64
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)
rw.WriteHeader(http.StatusOK)
}))).
ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
require.NoError(t, err)
t.Log(string(dump))
require.Equal(t, http.StatusOK, rw.Code)
require.Equal(t, int64(1), atomic.LoadInt64(&called))
})
t.Run("WorkspaceProxy", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
user = dbgen.User(t, db, database.User{})
_, userToken = dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
ExpiresAt: database.Now().AddDate(0, 0, 1),
})
proxy, proxyToken = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(codersdk.SessionTokenHeader, userToken)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID, proxyToken))
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})))).
ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
require.NoError(t, err)
t.Log(string(dump))
require.Equal(t, http.StatusBadRequest, rw.Code)
})
t.Run("Both", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
proxy, token = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID, token))
var called int64
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(
httpmw.RequireAPIKeyOrWorkspaceProxyAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)
rw.WriteHeader(http.StatusOK)
}))).
ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
require.NoError(t, err)
t.Log(string(dump))
require.Equal(t, http.StatusOK, rw.Code)
require.Equal(t, int64(1), atomic.LoadInt64(&called))
})
}
+33 -10
View File
@@ -47,9 +47,10 @@ type userAuthKey struct{}
type Authorization struct {
Actor rbac.Subject
// Username is required for logging and human friendly related
// identification.
Username string
// ActorName is required for logging and human friendly related identification.
// It is usually the "username" of the user, but it can be the name of the
// external workspace proxy or other service type actor.
ActorName string
}
// UserAuthorizationOptional may return the roles and scope used for
@@ -99,6 +100,10 @@ type ExtractAPIKeyConfig struct {
// will be deleted and the request will continue. If the request is not a
// cookie-based request, the request will be rejected with a 401.
Optional bool
// SessionTokenFunc is a custom function that can be used to extract the API
// key. If nil, the default behavior is used.
SessionTokenFunc func(r *http.Request) string
}
// ExtractAPIKeyMW calls ExtractAPIKey with the given config on each request,
@@ -145,7 +150,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
// like workspace applications.
write := func(code int, response codersdk.Response) (*database.APIKey, *Authorization, bool) {
if cfg.RedirectToLogin {
RedirectToLogin(rw, r, response.Message)
RedirectToLogin(rw, r, nil, response.Message)
return nil, nil, false
}
@@ -167,7 +172,11 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return nil, nil, false
}
token := apiTokenFromRequest(r)
tokenFunc := APITokenFromRequest
if cfg.SessionTokenFunc != nil {
tokenFunc = cfg.SessionTokenFunc
}
token := tokenFunc(r)
if token == "" {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
@@ -364,7 +373,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
// Actor is the user's authorization context.
authz := Authorization{
Username: roles.Username,
ActorName: roles.Username,
Actor: rbac.Subject{
ID: key.UserID.String(),
Roles: rbac.RoleNames(roles.Roles),
@@ -376,14 +385,14 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return &key, &authz, true
}
// apiTokenFromRequest returns the api token from the request.
// APITokenFromRequest returns the api token from the request.
// Find the session token from:
// 1: The cookie
// 1: The devurl cookie
// 3: The old cookie
// 4. The coder_session_token query parameter
// 5. The custom auth header
func apiTokenFromRequest(r *http.Request) string {
func APITokenFromRequest(r *http.Request) string {
cookie, err := r.Cookie(codersdk.SessionTokenCookie)
if err == nil && cookie.Value != "" {
return cookie.Value
@@ -432,7 +441,11 @@ func SplitAPIToken(token string) (id string, secret string, err error) {
// RedirectToLogin redirects the user to the login page with the `message` and
// `redirect` query parameters set.
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
//
// If dashboardURL is nil, the redirect will be relative to the current
// request's host. If it is not nil, the redirect will be absolute with dashboard
// url as the host.
func RedirectToLogin(rw http.ResponseWriter, r *http.Request, dashboardURL *url.URL, message string) {
path := r.URL.Path
if r.URL.RawQuery != "" {
path += "?" + r.URL.RawQuery
@@ -446,6 +459,16 @@ func RedirectToLogin(rw http.ResponseWriter, r *http.Request, message string) {
Path: "/login",
RawQuery: q.Encode(),
}
// If dashboardURL is provided, we want to redirect to the dashboard
// login page.
if dashboardURL != nil {
cpy := *dashboardURL
cpy.Path = u.Path
cpy.RawQuery = u.RawQuery
u = &cpy
}
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
// See other forces a GET request rather than keeping the current method
// (like temporary redirect does).
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
}
+2 -2
View File
@@ -73,7 +73,7 @@ func TestAPIKey(t *testing.T) {
location, err := res.Location()
require.NoError(t, err)
require.NotEmpty(t, location.Query().Get("message"))
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
})
t.Run("InvalidFormat", func(t *testing.T) {
@@ -526,7 +526,7 @@ func TestAPIKey(t *testing.T) {
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, res.StatusCode)
require.Equal(t, http.StatusSeeOther, res.StatusCode)
u, err := res.Location()
require.NoError(t, err)
require.Equal(t, "/login", u.Path)
+1 -1
View File
@@ -57,7 +57,7 @@ func ExtractUserParam(db database.Store, redirectToLoginOnMe bool) func(http.Han
apiKey, ok := APIKeyOptional(r)
if !ok {
if redirectToLoginOnMe {
RedirectToLogin(rw, r, SignedOutErrorMessage)
RedirectToLogin(rw, r, nil, SignedOutErrorMessage)
return
}
+1 -1
View File
@@ -32,7 +32,7 @@ func ExtractWorkspaceAgent(db database.Store) 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()
tokenValue := apiTokenFromRequest(r)
tokenValue := APITokenFromRequest(r)
if tokenValue == "" {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("Cookie %q must be provided.", codersdk.SessionTokenCookie),
+158
View File
@@ -0,0 +1,158 @@
package httpmw
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"net/http"
"strings"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
)
const (
// WorkspaceProxyAuthTokenHeader is the auth header used for requests from
// external workspace proxies.
//
// The format of an external proxy token is:
// <proxy id>:<proxy secret>
//
//nolint:gosec
WorkspaceProxyAuthTokenHeader = "Coder-External-Proxy-Token"
)
type workspaceProxyContextKey struct{}
// WorkspaceProxyOptional may return the workspace proxy from the ExtractWorkspaceProxy
// middleware.
func WorkspaceProxyOptional(r *http.Request) (database.WorkspaceProxy, bool) {
proxy, ok := r.Context().Value(workspaceProxyContextKey{}).(database.WorkspaceProxy)
return proxy, ok
}
// WorkspaceProxy returns the workspace proxy from the ExtractWorkspaceProxy
// middleware.
func WorkspaceProxy(r *http.Request) database.WorkspaceProxy {
proxy, ok := WorkspaceProxyOptional(r)
if !ok {
panic("developer error: ExtractWorkspaceProxy middleware not provided")
}
return proxy
}
type ExtractWorkspaceProxyConfig struct {
DB database.Store
// Optional indicates whether the middleware should be optional. If true,
// any requests without the external proxy auth token header will be
// allowed to continue and no workspace proxy will be set on the request
// context.
Optional bool
}
// ExtractWorkspaceProxy extracts the external workspace proxy from the request
// using the external proxy auth token header.
func ExtractWorkspaceProxy(opts ExtractWorkspaceProxyConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token := r.Header.Get(WorkspaceProxyAuthTokenHeader)
if token == "" {
if opts.Optional {
next.ServeHTTP(w, r)
return
}
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Missing required external proxy token",
})
return
}
// Split the token and lookup the corresponding workspace proxy.
parts := strings.Split(token, ":")
if len(parts) != 2 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
})
return
}
proxyID, err := uuid.Parse(parts[0])
if err != nil {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
})
return
}
secret := parts[1]
if len(secret) != 64 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
})
return
}
// Get the proxy.
// nolint:gocritic // Get proxy by ID to check auth token
proxy, err := opts.DB.GetWorkspaceProxyByID(dbauthz.AsSystemRestricted(ctx), proxyID)
if xerrors.Is(err, sql.ErrNoRows) {
// Proxy IDs are public so we don't care about leaking them via
// timing attacks.
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
Detail: "Proxy not found.",
})
return
}
if err != nil {
httpapi.InternalServerError(w, err)
return
}
if proxy.Deleted {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
Detail: "Proxy has been deleted.",
})
return
}
// Do a subtle constant time comparison of the hash of the secret.
hashedSecret := sha256.Sum256([]byte(secret))
if subtle.ConstantTimeCompare(proxy.TokenHashedSecret, hashedSecret[:]) != 1 {
httpapi.Write(ctx, w, http.StatusUnauthorized, codersdk.Response{
Message: "Invalid external proxy token",
Detail: "Invalid proxy token secret.",
})
return
}
ctx = r.Context()
ctx = context.WithValue(ctx, workspaceProxyContextKey{}, proxy)
//nolint:gocritic // Workspace proxies have full permissions. The
// workspace proxy auth middleware is not mounted to every route, so
// they can still only access the routes that the middleware is
// mounted to.
ctx = dbauthz.AsSystemRestricted(ctx)
subj, ok := dbauthz.ActorFromContext(ctx)
if !ok {
// This should never happen
httpapi.InternalServerError(w, xerrors.New("developer error: ExtractWorkspaceProxy missing rbac actor"))
return
}
// Use the same subject for the userAuthKey
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
Actor: subj,
ActorName: "proxy_" + proxy.Name,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
+163
View File
@@ -0,0 +1,163 @@
package httpmw_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbfake"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
)
func TestExtractWorkspaceProxy(t *testing.T) {
t.Parallel()
successHandler := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Only called if the API key passes through the handler.
httpapi.Write(context.Background(), rw, http.StatusOK, codersdk.Response{
Message: "It worked!",
})
})
t.Run("NoHeader", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidFormat", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, "test:wow-hello")
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidID", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, "test:wow")
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidSecretLength", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", uuid.NewString(), "wow"))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
secret, err := cryptorand.HexString(64)
require.NoError(t, err)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", uuid.NewString(), secret))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("InvalidSecret", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
proxy, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
)
// Use a different secret so they don't match!
secret, err := cryptorand.HexString(64)
require.NoError(t, err)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID.String(), secret))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(successHandler).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("Valid", func(t *testing.T) {
t.Parallel()
var (
db = dbfake.New()
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
proxy, secret = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
)
r.Header.Set(httpmw.WorkspaceProxyAuthTokenHeader, fmt.Sprintf("%s:%s", proxy.ID.String(), secret))
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: db,
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Checks that it exists on the context!
_ = httpmw.WorkspaceProxy(r)
successHandler.ServeHTTP(rw, r)
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
}
+3
View File
@@ -1005,11 +1005,14 @@ func (api *API) workspaceAgentCoordinate(rw http.ResponseWriter, r *http.Request
func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// This route accepts user API key auth and workspace proxy auth. The moon actor has
// full permissions so should be able to pass this authz check.
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(r, rbac.ActionCreate, workspace.ExecutionRBAC()) {
httpapi.ResourceNotFound(rw)
return
}
// This is used by Enterprise code to control the functionality of this route.
override := api.WorkspaceClientCoordinateOverride.Load()
if override != nil {
+36 -21
View File
@@ -1,11 +1,14 @@
package coderd
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"time"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
@@ -48,13 +51,6 @@ func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
// @Router /applications/auth-redirect [get]
func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if api.AppHostname == "" {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "The server does not accept subdomain-based application requests.",
})
return
}
apiKey := httpmw.APIKey(r)
if !api.Authorize(r, rbac.ActionCreate, apiKey) {
httpapi.ResourceNotFound(rw)
@@ -81,22 +77,41 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
// security purposes.
u.Scheme = api.AccessURL.Scheme
ok := false
if api.AppHostnameRegex != nil {
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
}
// Ensure that the redirect URI is a subdomain of api.Hostname and is a
// valid app subdomain.
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host)
if !ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The redirect_uri query parameter must be a valid app subdomain.",
})
return
}
_, err = httpapi.ParseSubdomainAppURL(subdomain)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The redirect_uri query parameter must be a valid app subdomain.",
Detail: err.Error(),
})
return
proxy, err := api.Database.GetWorkspaceProxyByHostname(ctx, u.Hostname())
if xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "The redirect_uri query parameter must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.",
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get workspace proxy by redirect_uri.",
Detail: err.Error(),
})
return
}
proxyURL, err := url.Parse(proxy.Url)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to parse workspace proxy URL.",
Detail: xerrors.Errorf("parse proxy URL %q: %w", proxy.Url, err).Error(),
})
return
}
// Force the redirect URI to use the same scheme as the proxy access URL
// for security purposes.
u.Scheme = proxyURL.Scheme
}
// Create the application_connect-scoped API key with the same lifetime as
@@ -139,5 +154,5 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
q := u.Query()
q.Set(workspaceapps.SubdomainProxyAPIKeyParam, encryptedAPIKey)
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
http.Redirect(rw, r, u.String(), http.StatusSeeOther)
}
+282 -244
View File
@@ -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()
+92 -63
View File
@@ -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)
+80 -60
View File
@@ -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
}
+128 -17
View File
@@ -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)
+43 -16
View File
@@ -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)
}
+167 -98
View File
@@ -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
}
+53 -3
View File
@@ -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
+22
View File
@@ -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
}
+180 -4
View File
@@ -3,6 +3,7 @@ package coderd_test
import (
"context"
"net"
"net/http"
"net/url"
"testing"
@@ -10,8 +11,13 @@ import (
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/coderd/database/dbtestutil"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/coderd/workspaceapps/apptest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
@@ -78,6 +84,171 @@ func TestGetAppHost(t *testing.T) {
}
}
func TestWorkspaceApplicationAuth(t *testing.T) {
t.Parallel()
cases := []struct {
name string
accessURL string
appHostname string
proxyURL string
proxyAppHostname string
redirectURI string
expectRedirect string
}{
{
name: "OK",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "https://something.test.coder.com",
expectRedirect: "https://something.test.coder.com",
},
{
name: "ProxyPathOK",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "https://proxy.test.coder.com/path",
expectRedirect: "https://proxy.test.coder.com/path",
},
{
name: "ProxySubdomainOK",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "https://something.proxy.test.coder.com/path?yeah=true",
expectRedirect: "https://something.proxy.test.coder.com/path?yeah=true",
},
{
name: "ProxySubdomainSuffixOK",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*--suffix.proxy.test.coder.com",
redirectURI: "https://something--suffix.proxy.test.coder.com/",
expectRedirect: "https://something--suffix.proxy.test.coder.com/",
},
{
name: "NormalizeSchemePrimaryAppHostname",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "http://x.test.coder.com",
expectRedirect: "https://x.test.coder.com",
},
{
name: "NormalizeSchemeProxyAppHostname",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "http://x.proxy.test.coder.com",
expectRedirect: "https://x.proxy.test.coder.com",
},
{
name: "NoneError",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "",
expectRedirect: "",
},
{
name: "PrimaryAccessURLError",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "https://test.coder.com/",
expectRedirect: "",
},
{
name: "OtherError",
accessURL: "https://test.coder.com",
appHostname: "*.test.coder.com",
proxyURL: "https://proxy.test.coder.com",
proxyAppHostname: "*.proxy.test.coder.com",
redirectURI: "https://example.com/",
expectRedirect: "",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
accessURL, err := url.Parse(c.accessURL)
require.NoError(t, err)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: pubsub,
AccessURL: accessURL,
AppHostname: c.appHostname,
})
_ = coderdtest.CreateFirstUser(t, client)
// Disable redirects.
client.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
_, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
Url: c.proxyURL,
WildcardHostname: c.proxyAppHostname,
})
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/applications/auth-redirect", nil, func(req *http.Request) {
q := req.URL.Query()
q.Set("redirect_uri", c.redirectURI)
req.URL.RawQuery = q.Encode()
})
require.NoError(t, err)
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
err = codersdk.ReadBodyAsError(resp)
if c.expectRedirect == "" {
require.Error(t, err)
return
}
require.NoError(t, err)
return
}
if c.expectRedirect == "" {
t.Fatal("expected a failure but got a success")
}
loc, err := resp.Location()
require.NoError(t, err)
q := loc.Query()
// Verify the API key is set.
encryptedAPIKey := loc.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
// Strip the API key from the actual redirect URI and compare.
q.Del(workspaceapps.SubdomainProxyAPIKeyParam)
loc.RawQuery = q.Encode()
require.Equal(t, c.expectRedirect, loc.String())
// The decrypted key is verified in the apptest test suite.
})
}
}
func TestWorkspaceApps(t *testing.T) {
t.Parallel()
@@ -87,6 +258,10 @@ func TestWorkspaceApps(t *testing.T) {
deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing)
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess)
if opts.DisableSubdomainApps {
opts.AppHost = ""
}
client := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: deploymentValues,
AppHostname: opts.AppHost,
@@ -105,10 +280,11 @@ func TestWorkspaceApps(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client)
return &apptest.Deployment{
Options: opts,
Client: client,
FirstUser: user,
PathAppBaseURL: client.URL,
Options: opts,
SDKClient: client,
FirstUser: user,
PathAppBaseURL: client.URL,
AppHostIsPrimary: true,
}
})
}
+10 -1
View File
@@ -79,6 +79,10 @@ type Client struct {
HTTPClient *http.Client
URL *url.URL
// SessionTokenHeader is an optional custom header to use for setting tokens. By
// default 'Coder-Session-Token' is used.
SessionTokenHeader string
// Logger is optionally provided to log requests.
// Method, URL, and response code will be logged by default.
Logger slog.Logger
@@ -150,7 +154,12 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
if err != nil {
return nil, xerrors.Errorf("create request: %w", err)
}
req.Header.Set(SessionTokenHeader, c.SessionToken())
tokenHeader := c.SessionTokenHeader
if tokenHeader == "" {
tokenHeader = SessionTokenHeader
}
req.Header.Set(tokenHeader, c.SessionToken())
if r != nil {
req.Header.Set("Content-Type", "application/json")
+14
View File
@@ -1575,6 +1575,20 @@ type BuildInfoResponse struct {
ExternalURL string `json:"external_url"`
// Version returns the semantic version of the build.
Version string `json:"version"`
// DashboardURL is the URL to hit the deployment's dashboard.
// For external workspace proxies, this is the coderd they are connected
// to.
DashboardURL string `json:"dashboard_url"`
WorkspaceProxy bool `json:"workspace_proxy"`
}
type WorkspaceProxyBuildInfo struct {
// TODO: @emyrk what should we include here?
WorkspaceProxy bool `json:"workspace_proxy"`
// DashboardURL is the URL of the coderd this proxy is connected to.
DashboardURL string `json:"dashboard_url"`
}
// CanonicalVersion trims build information from the version.
+7 -12
View File
@@ -200,18 +200,12 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
if err != nil {
return nil, xerrors.Errorf("parse url: %w", err)
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(coordinateURL, []*http.Cookie{{
Name: SessionTokenCookie,
Value: c.SessionToken(),
}})
httpClient := &http.Client{
Jar: jar,
Transport: c.HTTPClient.Transport,
coordinateHeaders := make(http.Header)
tokenHeader := SessionTokenHeader
if c.SessionTokenHeader != "" {
tokenHeader = c.SessionTokenHeader
}
coordinateHeaders.Set(tokenHeader, c.SessionToken())
ctx, cancel := context.WithCancel(ctx)
defer func() {
if err != nil {
@@ -227,7 +221,8 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
options.Logger.Debug(ctx, "connecting")
// nolint:bodyclose
ws, res, err := websocket.Dial(ctx, coordinateURL.String(), &websocket.DialOptions{
HTTPClient: httpClient,
HTTPClient: c.HTTPClient,
HTTPHeader: coordinateHeaders,
// Need to disable compression to avoid a data-race.
CompressionMode: websocket.CompressionDisabled,
})
+21 -17
View File
@@ -11,19 +11,10 @@ import (
"github.com/google/uuid"
)
type CreateWorkspaceProxyRequest struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
URL string `json:"url"`
WildcardHostname string `json:"wildcard_hostname"`
}
type WorkspaceProxy struct {
ID uuid.UUID `db:"id" json:"id" format:"uuid"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id" format:"uuid"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
ID uuid.UUID `db:"id" json:"id" format:"uuid"`
Name string `db:"name" json:"name"`
Icon string `db:"icon" json:"icon"`
// Full url including scheme of the proxy api url: https://us.example.com
URL string `db:"url" json:"url"`
// WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com
@@ -33,24 +24,37 @@ type WorkspaceProxy struct {
Deleted bool `db:"deleted" json:"deleted"`
}
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (WorkspaceProxy, error) {
type CreateWorkspaceProxyRequest struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Icon string `json:"icon"`
URL string `json:"url"`
WildcardHostname string `json:"wildcard_hostname"`
}
type CreateWorkspaceProxyResponse struct {
Proxy WorkspaceProxy `json:"proxy"`
ProxyToken string `json:"proxy_token"`
}
func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspaceProxyRequest) (CreateWorkspaceProxyResponse, error) {
res, err := c.Request(ctx, http.MethodPost,
"/api/v2/workspaceproxies",
req,
)
if err != nil {
return WorkspaceProxy{}, xerrors.Errorf("make request: %w", err)
return CreateWorkspaceProxyResponse{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return WorkspaceProxy{}, ReadBodyAsError(res)
return CreateWorkspaceProxyResponse{}, ReadBodyAsError(res)
}
var resp WorkspaceProxy
var resp CreateWorkspaceProxyResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) WorkspaceProxiesByOrganization(ctx context.Context) ([]WorkspaceProxy, error) {
func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) {
res, err := c.Request(ctx, http.MethodGet,
"/api/v2/workspaceproxies",
nil,
+1 -1
View File
@@ -20,7 +20,7 @@ We track the following resources:
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/audit-logs.md'. -->
+57 -2
View File
@@ -1185,7 +1185,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies \
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"updated_at": "2019-08-24T14:15:22Z",
"url": "string",
"wildcard_hostname": "string"
@@ -1211,9 +1210,65 @@ Status Code **200**
| `» icon` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
| `» organization_id` | string(uuid) | false | | |
| `» updated_at` | string(date-time) | false | | |
| `» url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com |
| `» wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Create workspace proxy
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /workspaceproxies`
> Body parameter
```json
{
"display_name": "string",
"icon": "string",
"name": "string",
"url": "string",
"wildcard_hostname": "string"
}
```
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
| `body` | body | [codersdk.CreateWorkspaceProxyRequest](schemas.md#codersdkcreateworkspaceproxyrequest) | true | Create workspace proxy request |
### Example responses
> 201 Response
```json
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"updated_at": "2019-08-24T14:15:22Z",
"url": "string",
"wildcard_hostname": "string"
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------ |
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+3 -1
View File
@@ -53,8 +53,10 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \
```json
{
"dashboard_url": "string",
"external_url": "string",
"version": "string"
"version": "string",
"workspace_proxy": true
}
```
+94 -7
View File
@@ -1141,17 +1141,21 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
```json
{
"dashboard_url": "string",
"external_url": "string",
"version": "string"
"version": "string",
"workspace_proxy": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| -------------- | ------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. |
| `version` | string | false | | Version returns the semantic version of the build. |
| Name | Type | Required | Restrictions | Description |
| ----------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. |
| `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. |
| `version` | string | false | | Version returns the semantic version of the build. |
| `workspace_proxy` | boolean | false | | |
## codersdk.BuildReason
@@ -5162,7 +5166,6 @@ Parameter represents a set value for the scope.
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"updated_at": "2019-08-24T14:15:22Z",
"url": "string",
"wildcard_hostname": "string"
@@ -5178,7 +5181,6 @@ Parameter represents a set value for the scope.
| `icon` | string | false | | |
| `id` | string | false | | |
| `name` | string | false | | |
| `organization_id` | string | false | | |
| `updated_at` | string | false | | |
| `url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com |
| `wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com |
@@ -6286,3 +6288,88 @@ RegionIDs in range 900-999 are reserved for end users to run their own DERP node
### Properties
_None_
## workspaceapps.AccessMethod
```json
"path"
```
### Properties
#### Enumerated Values
| Value |
| ----------- |
| `path` |
| `subdomain` |
| `terminal` |
## workspaceapps.IssueTokenRequest
```json
{
"app_hostname": "string",
"app_path": "string",
"app_query": "string",
"app_request": {
"access_method": "path",
"agent_name_or_id": "string",
"app_slug_or_port": "string",
"base_path": "string",
"username_or_id": "string",
"workspace_name_or_id": "string"
},
"path_app_base_url": "string",
"session_token": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------- | ---------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------- |
| `app_hostname` | string | false | | App hostname is the optional hostname for subdomain apps on the external proxy. It must start with an asterisk. |
| `app_path` | string | false | | App path is the path of the user underneath the app base path. |
| `app_query` | string | false | | App query is the query parameters the user provided in the app request. |
| `app_request` | [workspaceapps.Request](#workspaceappsrequest) | false | | |
| `path_app_base_url` | string | false | | Path app base URL is required. |
| `session_token` | string | false | | Session token is the session token provided by the user. |
## workspaceapps.Request
```json
{
"access_method": "path",
"agent_name_or_id": "string",
"app_slug_or_port": "string",
"base_path": "string",
"username_or_id": "string",
"workspace_name_or_id": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------------------- | -------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `access_method` | [workspaceapps.AccessMethod](#workspaceappsaccessmethod) | false | | |
| `agent_name_or_id` | string | false | | Agent name or ID is not required if the workspace has only one agent. |
| `app_slug_or_port` | string | false | | |
| `base_path` | string | false | | Base path of the app. For path apps, this is the path prefix in the router for this particular app. For subdomain apps, this should be "/". This is used for setting the cookie path. |
| `username_or_id` | string | false | | For the following fields, if the AccessMethod is AccessMethodTerminal, then only AgentNameOrID may be set and it must be a UUID. The other fields must be left blank. |
| `workspace_name_or_id` | string | false | | |
## wsproxysdk.IssueSignedAppTokenResponse
```json
{
"signed_token_str": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------ | -------- | ------------ | ----------------------------------------------------------- |
| `signed_token_str` | string | false | | Signed token str should be set as a cookie on the response. |
-58
View File
@@ -2472,61 +2472,3 @@ Status Code **200**
| `type` | `bool` |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Create workspace proxy
### Code samples
```shell
# Example request using curl
curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`POST /workspaceproxies`
> Body parameter
```json
{
"display_name": "string",
"icon": "string",
"name": "string",
"url": "string",
"wildcard_hostname": "string"
}
```
### Parameters
| Name | In | Type | Required | Description |
| ------ | ---- | -------------------------------------------------------------------------------------- | -------- | ------------------------------ |
| `body` | body | [codersdk.CreateWorkspaceProxyRequest](schemas.md#codersdkcreateworkspaceproxyrequest) | true | Create workspace proxy request |
### Example responses
> 201 Response
```json
{
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"updated_at": "2019-08-24T14:15:22Z",
"url": "string",
"wildcard_hostname": "string"
}
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------------ | ----------- | ------------------------------------------------------------ |
| 201 | [Created](https://tools.ietf.org/html/rfc7231#section-6.3.2) | Created | [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+10 -9
View File
@@ -163,15 +163,16 @@ var auditableResourcesTypes = map[any]map[string]Action{
"uuid": ActionTrack,
},
&database.WorkspaceProxy{}: {
"id": ActionTrack,
"name": ActionTrack,
"display_name": ActionTrack,
"icon": ActionTrack,
"url": ActionTrack,
"wildcard_hostname": ActionTrack,
"created_at": ActionTrack,
"updated_at": ActionTrack,
"deleted": ActionTrack,
"id": ActionTrack,
"name": ActionTrack,
"display_name": ActionTrack,
"icon": ActionTrack,
"url": ActionTrack,
"wildcard_hostname": ActionTrack,
"created_at": ActionTrack,
"updated_at": ActionIgnore,
"deleted": ActionIgnore,
"token_hashed_secret": ActionSecret,
},
}
+16 -3
View File
@@ -83,11 +83,24 @@ func New(ctx context.Context, options *Options) (*API, error) {
})
r.Route("/workspaceproxies", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
api.moonsEnabledMW,
)
r.Post("/", api.postWorkspaceProxy)
r.Get("/", api.workspaceProxies)
r.Group(func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Post("/", api.postWorkspaceProxy)
r.Get("/", api.workspaceProxies)
})
r.Route("/me", func(r chi.Router) {
r.Use(
httpmw.ExtractWorkspaceProxy(httpmw.ExtractWorkspaceProxyConfig{
DB: options.Database,
Optional: false,
}),
)
r.Post("/issue-signed-app-token", api.workspaceProxyIssueSignedAppToken)
})
// TODO: Add specific workspace proxy endpoints.
// r.Route("/{proxyName}", func(r chi.Router) {
// r.Use(
@@ -0,0 +1,142 @@
package coderdenttest
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"sync"
"testing"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd"
"github.com/coder/coder/enterprise/wsproxy"
)
type ProxyOptions struct {
Name string
TLSCertificates []tls.Certificate
AppHostname string
DisablePathApps bool
// ProxyURL is optional
ProxyURL *url.URL
}
// NewWorkspaceProxy will configure a wsproxy.Server with the given options.
// The new wsproxy will register itself with the given coderd.API instance.
// The first user owner client is required to create the wsproxy on the coderd
// api server.
func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Client, options *ProxyOptions) *wsproxy.Server {
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(cancelFunc)
if options == nil {
options = &ProxyOptions{}
}
// HTTP Server
var mutex sync.RWMutex
var handler http.Handler
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mutex.RLock()
defer mutex.RUnlock()
if handler == nil {
http.Error(w, "handler not set", http.StatusServiceUnavailable)
}
handler.ServeHTTP(w, r)
}))
srv.Config.BaseContext = func(_ net.Listener) context.Context {
return ctx
}
if options.TLSCertificates != nil {
srv.TLS = &tls.Config{
Certificates: options.TLSCertificates,
MinVersion: tls.VersionTLS12,
}
srv.StartTLS()
} else {
srv.Start()
}
t.Cleanup(srv.Close)
tcpAddr, ok := srv.Listener.Addr().(*net.TCPAddr)
require.True(t, ok)
serverURL, err := url.Parse(srv.URL)
require.NoError(t, err)
serverURL.Host = fmt.Sprintf("localhost:%d", tcpAddr.Port)
accessURL := options.ProxyURL
if accessURL == nil {
accessURL = serverURL
}
// TODO: Stun and derp stuff
// derpPort, err := strconv.Atoi(serverURL.Port())
// require.NoError(t, err)
//
// stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{})
// t.Cleanup(stunCleanup)
//
// derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp").Leveled(slog.LevelDebug)))
// derpServer.SetMeshKey("test-key")
var appHostnameRegex *regexp.Regexp
if options.AppHostname != "" {
var err error
appHostnameRegex, err = httpapi.CompileHostnamePattern(options.AppHostname)
require.NoError(t, err)
}
if options.Name == "" {
options.Name = namesgenerator.GetRandomName(1)
}
proxyRes, err := owner.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: options.Name,
Icon: "/emojis/flag.png",
URL: accessURL.String(),
WildcardHostname: options.AppHostname,
})
require.NoError(t, err, "failed to create workspace proxy")
wssrv, err := wsproxy.New(&wsproxy.Options{
Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug),
DashboardURL: coderdAPI.AccessURL,
AccessURL: accessURL,
AppHostname: options.AppHostname,
AppHostnameRegex: appHostnameRegex,
RealIPConfig: coderdAPI.RealIPConfig,
AppSecurityKey: coderdAPI.AppSecurityKey,
Tracing: coderdAPI.TracerProvider,
APIRateLimit: coderdAPI.APIRateLimit,
SecureAuthCookie: coderdAPI.SecureAuthCookie,
ProxySessionToken: proxyRes.ProxyToken,
DisablePathApps: options.DisablePathApps,
// We need a new registry to not conflict with the coderd internal
// proxy metrics.
PrometheusRegistry: prometheus.NewRegistry(),
})
require.NoError(t, err)
mutex.Lock()
handler = wssrv.Handler
mutex.Unlock()
return wssrv
}
+87 -16
View File
@@ -1,6 +1,7 @@
package coderd
import (
"crypto/sha256"
"database/sql"
"fmt"
"net/http"
@@ -12,7 +13,10 @@ import (
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
)
// @Summary Create workspace proxy
@@ -20,7 +24,7 @@ import (
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Templates
// @Tags Enterprise
// @Param request body codersdk.CreateWorkspaceProxyRequest true "Create workspace proxy request"
// @Success 201 {object} codersdk.WorkspaceProxy
// @Router /workspaceproxies [post]
@@ -50,23 +54,35 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
return
}
if _, err := httpapi.CompileHostnamePattern(req.WildcardHostname); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Wildcard URL is invalid.",
Detail: err.Error(),
})
return
if req.WildcardHostname != "" {
if _, err := httpapi.CompileHostnamePattern(req.WildcardHostname); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Wildcard URL is invalid.",
Detail: err.Error(),
})
return
}
}
id := uuid.New()
secret, err := cryptorand.HexString(64)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
hashedSecret := sha256.Sum256([]byte(secret))
fullToken := fmt.Sprintf("%s:%s", id, secret)
proxy, err := api.Database.InsertWorkspaceProxy(ctx, database.InsertWorkspaceProxyParams{
ID: uuid.New(),
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
Url: req.URL,
WildcardHostname: req.WildcardHostname,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
ID: id,
Name: req.Name,
DisplayName: req.DisplayName,
Icon: req.Icon,
Url: req.URL,
WildcardHostname: req.WildcardHostname,
TokenHashedSecret: hashedSecret[:],
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
if database.IsUniqueViolation(err) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
@@ -80,7 +96,10 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
}
aReq.New = proxy
httpapi.Write(ctx, rw, http.StatusCreated, convertProxy(proxy))
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.CreateWorkspaceProxyResponse{
Proxy: convertProxy(proxy),
ProxyToken: fullToken,
})
}
// nolint:revive
@@ -137,3 +156,55 @@ func convertProxy(p database.WorkspaceProxy) codersdk.WorkspaceProxy {
Deleted: p.Deleted,
}
}
// @Summary Issue signed workspace app token
// @ID issue-signed-workspace-app-token
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags Enterprise
// @Param request body workspaceapps.IssueTokenRequest true "Issue signed app token request"
// @Success 201 {object} wsproxysdk.IssueSignedAppTokenResponse
// @Router /workspaceproxies/me/issue-signed-app-token [post]
// @x-apidocgen {"skip": true}
func (api *API) workspaceProxyIssueSignedAppToken(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// NOTE: this endpoint will return JSON on success, but will (usually)
// return a self-contained HTML error page on failure. The external proxy
// should forward any non-201 response to the client.
var req workspaceapps.IssueTokenRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
// userReq is a http request from the user on the other side of the proxy.
// Although the workspace proxy is making this call, we want to use the user's
// authorization context to create the token.
//
// We can use the existing request context for all tracing/logging purposes.
// Any workspace proxy auth uses different context keys so we don't need to
// worry about that.
userReq, err := http.NewRequestWithContext(ctx, "GET", req.AppRequest.BasePath, nil)
if err != nil {
// This should never happen
httpapi.InternalServerError(rw, xerrors.Errorf("[DEV ERROR] new request: %w", err))
return
}
userReq.Header.Set(codersdk.SessionTokenHeader, req.SessionToken)
// Exchange the token.
token, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, userReq, req)
if !ok {
return
}
if token == nil {
httpapi.InternalServerError(rw, xerrors.New("nil token after calling token provider"))
return
}
httpapi.Write(ctx, rw, http.StatusCreated, wsproxysdk.IssueSignedAppTokenResponse{
SignedTokenStr: tokenStr,
})
}
+123 -3
View File
@@ -1,15 +1,27 @@
package coderd_test
import (
"net/http/httptest"
"net/http/httputil"
"testing"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/agent"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database/dbtestutil"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/codersdk/agentsdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/enterprise/coderd/license"
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/testutil"
)
@@ -36,7 +48,7 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
},
})
ctx := testutil.Context(t, testutil.WaitLong)
proxy, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
proxyRes, err := client.CreateWorkspaceProxy(ctx, codersdk.CreateWorkspaceProxyRequest{
Name: namesgenerator.GetRandomName(1),
Icon: "/emojis/flag.png",
URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
@@ -44,9 +56,117 @@ func TestWorkspaceProxyCRUD(t *testing.T) {
})
require.NoError(t, err)
proxies, err := client.WorkspaceProxiesByOrganization(ctx)
proxies, err := client.WorkspaceProxies(ctx)
require.NoError(t, err)
require.Len(t, proxies, 1)
require.Equal(t, proxy, proxies[0])
require.Equal(t, proxyRes.Proxy, proxies[0])
require.NotEmpty(t, proxyRes.ProxyToken)
})
}
func TestIssueSignedAppToken(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{
string(codersdk.ExperimentMoons),
"*",
}
db, pubsub := dbtestutil.NewDB(t)
client := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
Database: db,
Pubsub: pubsub,
IncludeProvisionerDaemon: true,
},
})
user := coderdtest.CreateFirstUser(t, client)
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceProxy: 1,
},
})
// Create a workspace + apps
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
build := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
workspace.LatestBuild = build
// Connect an agent to the workspace
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken)
agentCloser := agent.New(agent.Options{
Client: agentClient,
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
})
t.Cleanup(func() {
_ = agentCloser.Close()
})
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
createProxyCtx := testutil.Context(t, testutil.WaitLong)
proxyRes, err := client.CreateWorkspaceProxy(createProxyCtx, codersdk.CreateWorkspaceProxyRequest{
Name: namesgenerator.GetRandomName(1),
Icon: "/emojis/flag.png",
URL: "https://" + namesgenerator.GetRandomName(1) + ".com",
WildcardHostname: "*.sub.example.com",
})
require.NoError(t, err)
proxyClient := wsproxysdk.New(client.URL)
proxyClient.SetSessionToken(proxyRes.ProxyToken)
t.Run("BadAppRequest", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, err = proxyClient.IssueSignedAppToken(ctx, workspaceapps.IssueTokenRequest{
// Invalid request.
AppRequest: workspaceapps.Request{},
SessionToken: client.SessionToken(),
})
require.Error(t, err)
})
goodRequest := workspaceapps.IssueTokenRequest{
AppRequest: workspaceapps.Request{
BasePath: "/app",
AccessMethod: workspaceapps.AccessMethodTerminal,
AgentNameOrID: build.Resources[0].Agents[0].ID.String(),
},
SessionToken: client.SessionToken(),
}
t.Run("OK", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
_, err = proxyClient.IssueSignedAppToken(ctx, goodRequest)
require.NoError(t, err)
})
t.Run("OKHTML", func(t *testing.T) {
t.Parallel()
rw := httptest.NewRecorder()
ctx := testutil.Context(t, testutil.WaitLong)
_, ok := proxyClient.IssueSignedAppTokenHTML(ctx, rw, goodRequest)
if !assert.True(t, ok, "expected true") {
resp := rw.Result()
defer resp.Body.Close()
dump, err := httputil.DumpResponse(resp, true)
require.NoError(t, err)
t.Log(string(dump))
}
})
}
+58
View File
@@ -0,0 +1,58 @@
package wsproxy
import (
"context"
"net/http"
"net/url"
"cdr.dev/slog"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
)
var _ workspaceapps.SignedTokenProvider = (*TokenProvider)(nil)
type TokenProvider struct {
DashboardURL *url.URL
AccessURL *url.URL
AppHostname string
Client *wsproxysdk.Client
SecurityKey workspaceapps.SecurityKey
Logger slog.Logger
}
func (p *TokenProvider) FromRequest(r *http.Request) (*workspaceapps.SignedToken, bool) {
return workspaceapps.FromRequest(r, p.SecurityKey)
}
func (p *TokenProvider) Issue(ctx context.Context, rw http.ResponseWriter, r *http.Request, issueReq workspaceapps.IssueTokenRequest) (*workspaceapps.SignedToken, string, bool) {
appReq := issueReq.AppRequest.Normalize()
err := appReq.Validate()
if err != nil {
workspaceapps.WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "invalid app request")
return nil, "", false
}
issueReq.AppRequest = appReq
resp, ok := p.Client.IssueSignedAppTokenHTML(ctx, rw, issueReq)
if !ok {
return nil, "", false
}
// Check that it verifies properly and matches the string.
token, err := p.SecurityKey.VerifySignedToken(resp.SignedTokenStr)
if err != nil {
workspaceapps.WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "failed to verify newly generated signed token")
return nil, "", false
}
// Check that it matches the request.
if !token.MatchesRequest(appReq) {
workspaceapps.WriteWorkspaceApp500(p.Logger, p.DashboardURL, rw, r, &appReq, err, "newly generated signed token does not match request")
return nil, "", false
}
return &token, resp.SignedTokenStr, true
}
+250
View File
@@ -0,0 +1,250 @@
package wsproxy
import (
"context"
"net/http"
"net/url"
"reflect"
"regexp"
"strings"
"time"
"github.com/coder/coder/coderd/httpapi"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
)
type Options struct {
Logger slog.Logger
// DashboardURL is the URL of the primary coderd instance.
DashboardURL *url.URL
// AccessURL is the URL of the WorkspaceProxy. This is the url to communicate
// with this server.
AccessURL *url.URL
// TODO: @emyrk We use these two fields in many places with this comment.
// Maybe we should make some shared options struct?
// AppHostname should be the wildcard hostname to use for workspace
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
// It will use the same scheme and port number as the access URL.
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
AppHostname string
// AppHostnameRegex contains the regex version of options.AppHostname as
// generated by httpapi.CompileHostnamePattern(). It MUST be set if
// options.AppHostname is set.
AppHostnameRegex *regexp.Regexp
RealIPConfig *httpmw.RealIPConfig
// TODO: @emyrk this key needs to be provided via a file or something?
// Maybe we should curl it from the primary over some secure connection?
AppSecurityKey workspaceapps.SecurityKey
Tracing trace.TracerProvider
PrometheusRegistry *prometheus.Registry
APIRateLimit int
SecureAuthCookie bool
DisablePathApps bool
ProxySessionToken string
}
func (o *Options) Validate() error {
var errs optErrors
errs.Required("Logger", o.Logger)
errs.Required("DashboardURL", o.DashboardURL)
errs.Required("AccessURL", o.AccessURL)
errs.Required("RealIPConfig", o.RealIPConfig)
errs.Required("PrometheusRegistry", o.PrometheusRegistry)
errs.NotEmpty("ProxySessionToken", o.ProxySessionToken)
errs.NotEmpty("AppSecurityKey", o.AppSecurityKey)
if len(errs) > 0 {
return errs
}
return nil
}
// Server is an external workspace proxy server. This server can communicate
// directly with a workspace. It requires a primary coderd to establish a said
// connection.
type Server struct {
Options *Options
Handler chi.Router
DashboardURL *url.URL
AppServer *workspaceapps.Server
// Logging/Metrics
Logger slog.Logger
TracerProvider trace.TracerProvider
PrometheusRegistry *prometheus.Registry
// SDKClient is a client to the primary coderd instance authenticated with
// the moon's token.
SDKClient *wsproxysdk.Client
// TODO: Missing:
// - derpserver
// Used for graceful shutdown. Required for the dialer.
ctx context.Context
cancel context.CancelFunc
}
func New(opts *Options) (*Server, error) {
if opts.PrometheusRegistry == nil {
opts.PrometheusRegistry = prometheus.NewRegistry()
}
if err := opts.Validate(); err != nil {
return nil, err
}
// TODO: implement some ping and registration logic
client := wsproxysdk.New(opts.DashboardURL)
err := client.SetSessionToken(opts.ProxySessionToken)
if err != nil {
return nil, xerrors.Errorf("set client token: %w", err)
}
r := chi.NewRouter()
ctx, cancel := context.WithCancel(context.Background())
s := &Server{
Options: opts,
Handler: r,
DashboardURL: opts.DashboardURL,
Logger: opts.Logger.Named("workspace-proxy"),
TracerProvider: opts.Tracing,
PrometheusRegistry: opts.PrometheusRegistry,
SDKClient: client,
ctx: ctx,
cancel: cancel,
}
s.AppServer = &workspaceapps.Server{
Logger: opts.Logger.Named("workspaceapps"),
DashboardURL: opts.DashboardURL,
AccessURL: opts.AccessURL,
Hostname: opts.AppHostname,
HostnameRegex: opts.AppHostnameRegex,
RealIPConfig: opts.RealIPConfig,
SignedTokenProvider: &TokenProvider{
DashboardURL: opts.DashboardURL,
AccessURL: opts.AccessURL,
AppHostname: opts.AppHostname,
Client: client,
SecurityKey: s.Options.AppSecurityKey,
Logger: s.Logger.Named("proxy_token_provider"),
},
WorkspaceConnCache: wsconncache.New(s.DialWorkspaceAgent, 0),
AppSecurityKey: opts.AppSecurityKey,
DisablePathApps: opts.DisablePathApps,
SecureAuthCookie: opts.SecureAuthCookie,
}
// Routes
apiRateLimiter := httpmw.RateLimit(opts.APIRateLimit, time.Minute)
// Persistent middlewares to all routes
r.Use(
// TODO: @emyrk Should we standardize these in some other package?
httpmw.Recover(s.Logger),
tracing.StatusWriterMiddleware,
tracing.Middleware(s.TracerProvider),
httpmw.AttachRequestID,
httpmw.ExtractRealIP(s.Options.RealIPConfig),
httpmw.Logger(s.Logger),
httpmw.Prometheus(s.PrometheusRegistry),
// HandleSubdomain is a middleware that handles all requests to the
// subdomain-based workspace apps.
s.AppServer.HandleSubdomain(apiRateLimiter),
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Coder-Build-Version", buildinfo.Version())
next.ServeHTTP(w, r)
})
},
// This header stops a browser from trying to MIME-sniff the content type and
// forces it to stick with the declared content-type. This is the only valid
// value for this header.
// See: https://github.com/coder/security/issues/12
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
},
// TODO: @emyrk we might not need this? But good to have if it does
// not break anything.
httpmw.CSRF(s.Options.SecureAuthCookie),
)
// Attach workspace apps routes.
r.Group(func(r chi.Router) {
r.Use(apiRateLimiter)
s.AppServer.Attach(r)
})
r.Get("/buildinfo", s.buildInfo)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) })
return s, nil
}
func (s *Server) Close() error {
s.cancel()
return s.AppServer.Close()
}
func (s *Server) DialWorkspaceAgent(id uuid.UUID) (*codersdk.WorkspaceAgentConn, error) {
return s.SDKClient.DialWorkspaceAgent(s.ctx, id, nil)
}
func (s *Server) buildInfo(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
Version: buildinfo.Version(),
DashboardURL: s.DashboardURL.String(),
})
}
type optErrors []error
func (e optErrors) Error() string {
var b strings.Builder
for _, err := range e {
_, _ = b.WriteString(err.Error())
_, _ = b.WriteString("\n")
}
return b.String()
}
func (e *optErrors) Required(name string, v any) {
if v == nil {
*e = append(*e, xerrors.Errorf("%s is required, got <nil>", name))
}
}
func (e *optErrors) NotEmpty(name string, v any) {
if reflect.ValueOf(v).IsZero() {
*e = append(*e, xerrors.Errorf("%s is required, got the zero value", name))
}
}
+71
View File
@@ -0,0 +1,71 @@
package wsproxy_test
import (
"net"
"testing"
"github.com/coder/coder/cli/clibase"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/workspaceapps/apptest"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
"github.com/coder/coder/enterprise/coderd/license"
)
func TestWorkspaceProxyWorkspaceApps(t *testing.T) {
t.Parallel()
apptest.Run(t, func(t *testing.T, opts *apptest.DeploymentOptions) *apptest.Deployment {
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.DisablePathApps = clibase.Bool(opts.DisablePathApps)
deploymentValues.Dangerous.AllowPathAppSharing = clibase.Bool(opts.DangerousAllowPathAppSharing)
deploymentValues.Dangerous.AllowPathAppSiteOwnerAccess = clibase.Bool(opts.DangerousAllowPathAppSiteOwnerAccess)
deploymentValues.Experiments = []string{
string(codersdk.ExperimentMoons),
"*",
}
client, _, api := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: deploymentValues,
AppHostname: "*.primary.test.coder.com",
IncludeProvisionerDaemon: true,
RealIPConfig: &httpmw.RealIPConfig{
TrustedOrigins: []*net.IPNet{{
IP: net.ParseIP("127.0.0.1"),
Mask: net.CIDRMask(8, 32),
}},
TrustedHeaders: []string{
"CF-Connecting-IP",
},
},
},
})
user := coderdtest.CreateFirstUser(t, client)
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureWorkspaceProxy: 1,
},
})
// Create the external proxy
if opts.DisableSubdomainApps {
opts.AppHost = ""
}
proxyAPI := coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
Name: "best-proxy",
AppHostname: opts.AppHost,
DisablePathApps: opts.DisablePathApps,
})
return &apptest.Deployment{
Options: opts,
SDKClient: client,
FirstUser: user,
PathAppBaseURL: proxyAPI.Options.AccessURL,
AppHostIsPrimary: false,
}
})
}
+144
View File
@@ -0,0 +1,144 @@
package wsproxysdk
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/codersdk"
)
// Client is a HTTP client for a subset of Coder API routes that external
// proxies need.
type Client struct {
SDKClient *codersdk.Client
// HACK: the issue-signed-app-token requests may issue redirect responses
// (which need to be forwarded to the client), so the client we use to make
// those requests must ignore redirects.
sdkClientIgnoreRedirects *codersdk.Client
}
// New creates a external proxy client for the provided primary coder server
// URL.
func New(serverURL *url.URL) *Client {
sdkClient := codersdk.New(serverURL)
sdkClient.SessionTokenHeader = httpmw.WorkspaceProxyAuthTokenHeader
sdkClientIgnoreRedirects := codersdk.New(serverURL)
sdkClientIgnoreRedirects.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
sdkClientIgnoreRedirects.SessionTokenHeader = httpmw.WorkspaceProxyAuthTokenHeader
return &Client{
SDKClient: sdkClient,
sdkClientIgnoreRedirects: sdkClientIgnoreRedirects,
}
}
// SetSessionToken sets the session token for the client. An error is returned
// if the session token is not in the correct format for external proxies.
func (c *Client) SetSessionToken(token string) error {
c.SDKClient.SetSessionToken(token)
c.sdkClientIgnoreRedirects.SetSessionToken(token)
return nil
}
// SessionToken returns the currently set token for the client.
func (c *Client) SessionToken() string {
return c.SDKClient.SessionToken()
}
// Request wraps the underlying codersdk.Client's Request method.
func (c *Client) Request(ctx context.Context, method, path string, body interface{}, opts ...codersdk.RequestOption) (*http.Response, error) {
return c.SDKClient.Request(ctx, method, path, body, opts...)
}
// RequestIgnoreRedirects wraps the underlying codersdk.Client's Request method
// on the client that ignores redirects.
func (c *Client) RequestIgnoreRedirects(ctx context.Context, method, path string, body interface{}, opts ...codersdk.RequestOption) (*http.Response, error) {
return c.sdkClientIgnoreRedirects.Request(ctx, method, path, body, opts...)
}
// DialWorkspaceAgent calls the underlying codersdk.Client's DialWorkspaceAgent
// method.
func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, options *codersdk.DialWorkspaceAgentOptions) (agentConn *codersdk.WorkspaceAgentConn, err error) {
return c.SDKClient.DialWorkspaceAgent(ctx, agentID, options)
}
type IssueSignedAppTokenResponse struct {
// SignedTokenStr should be set as a cookie on the response.
SignedTokenStr string `json:"signed_token_str"`
}
// IssueSignedAppToken issues a new signed app token for the provided app
// request. The error page will be returned as JSON. For use in external
// proxies, use IssueSignedAppTokenHTML instead.
func (c *Client) IssueSignedAppToken(ctx context.Context, req workspaceapps.IssueTokenRequest) (IssueSignedAppTokenResponse, error) {
resp, err := c.RequestIgnoreRedirects(ctx, http.MethodPost, "/api/v2/workspaceproxies/me/issue-signed-app-token", req, func(r *http.Request) {
// This forces any HTML error pages to be returned as JSON instead.
r.Header.Set("Accept", "application/json")
})
if err != nil {
return IssueSignedAppTokenResponse{}, xerrors.Errorf("make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return IssueSignedAppTokenResponse{}, codersdk.ReadBodyAsError(resp)
}
var res IssueSignedAppTokenResponse
return res, json.NewDecoder(resp.Body).Decode(&res)
}
// IssueSignedAppTokenHTML issues a new signed app token for the provided app
// request. The error page will be returned as HTML in most cases, and will be
// written directly to the provided http.ResponseWriter.
func (c *Client) IssueSignedAppTokenHTML(ctx context.Context, rw http.ResponseWriter, req workspaceapps.IssueTokenRequest) (IssueSignedAppTokenResponse, bool) {
writeError := func(rw http.ResponseWriter, err error) {
res := codersdk.Response{
Message: "Internal server error",
Detail: err.Error(),
}
rw.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(rw).Encode(res)
}
resp, err := c.RequestIgnoreRedirects(ctx, http.MethodPost, "/api/v2/workspaceproxies/me/issue-signed-app-token", req, func(r *http.Request) {
r.Header.Set("Accept", "text/html")
})
if err != nil {
writeError(rw, xerrors.Errorf("perform issue signed app token request: %w", err))
return IssueSignedAppTokenResponse{}, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
// Copy the response to the ResponseWriter.
for k, v := range resp.Header {
rw.Header()[k] = v
}
rw.WriteHeader(resp.StatusCode)
_, err = io.Copy(rw, resp.Body)
if err != nil {
writeError(rw, xerrors.Errorf("copy response body: %w", err))
}
return IssueSignedAppTokenResponse{}, false
}
var res IssueSignedAppTokenResponse
err = json.NewDecoder(resp.Body).Decode(&res)
if err != nil {
writeError(rw, xerrors.Errorf("decode response body: %w", err))
return IssueSignedAppTokenResponse{}, false
}
return res, true
}
@@ -0,0 +1,180 @@
package wsproxysdk_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/enterprise/wsproxy/wsproxysdk"
"github.com/coder/coder/testutil"
)
func Test_IssueSignedAppTokenHTML(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
var (
expectedProxyToken = "hi:test"
expectedAppReq = workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/@user/workspace/apps/slug",
UsernameOrID: "user",
WorkspaceNameOrID: "workspace",
AppSlugOrPort: "slug",
}
expectedSessionToken = "user-session-token"
expectedSignedTokenStr = "signed-app-token"
)
var called int64
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)
assert.Equal(t, r.Method, http.MethodPost)
assert.Equal(t, r.URL.Path, "/api/v2/workspaceproxies/me/issue-signed-app-token")
assert.Equal(t, r.Header.Get(httpmw.WorkspaceProxyAuthTokenHeader), expectedProxyToken)
var req workspaceapps.IssueTokenRequest
err := json.NewDecoder(r.Body).Decode(&req)
assert.NoError(t, err)
assert.Equal(t, req.AppRequest, expectedAppReq)
assert.Equal(t, req.SessionToken, expectedSessionToken)
rw.WriteHeader(http.StatusCreated)
err = json.NewEncoder(rw).Encode(wsproxysdk.IssueSignedAppTokenResponse{
SignedTokenStr: expectedSignedTokenStr,
})
assert.NoError(t, err)
}))
u, err := url.Parse(srv.URL)
require.NoError(t, err)
client := wsproxysdk.New(u)
client.SetSessionToken(expectedProxyToken)
ctx := testutil.Context(t, testutil.WaitLong)
rw := newResponseRecorder()
tokenRes, ok := client.IssueSignedAppTokenHTML(ctx, rw, workspaceapps.IssueTokenRequest{
AppRequest: expectedAppReq,
SessionToken: expectedSessionToken,
})
if !assert.True(t, ok) {
t.Log("issue request failed when it should've succeeded")
t.Log("response dump:")
res := rw.Result()
defer res.Body.Close()
dump, err := httputil.DumpResponse(res, true)
if err != nil {
t.Logf("failed to dump response: %v", err)
} else {
t.Log(string(dump))
}
t.FailNow()
}
require.Equal(t, expectedSignedTokenStr, tokenRes.SignedTokenStr)
require.False(t, rw.WasWritten())
require.EqualValues(t, called, 1)
})
t.Run("Error", func(t *testing.T) {
t.Parallel()
var (
expectedProxyToken = "hi:test"
expectedResponseStatus = http.StatusBadRequest
expectedResponseBody = "bad request"
)
var called int64
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&called, 1)
assert.Equal(t, r.Method, http.MethodPost)
assert.Equal(t, r.URL.Path, "/api/v2/workspaceproxies/me/issue-signed-app-token")
assert.Equal(t, r.Header.Get(httpmw.WorkspaceProxyAuthTokenHeader), expectedProxyToken)
rw.WriteHeader(expectedResponseStatus)
_, _ = rw.Write([]byte(expectedResponseBody))
}))
u, err := url.Parse(srv.URL)
require.NoError(t, err)
client := wsproxysdk.New(u)
_ = client.SetSessionToken(expectedProxyToken)
ctx := testutil.Context(t, testutil.WaitLong)
rw := newResponseRecorder()
tokenRes, ok := client.IssueSignedAppTokenHTML(ctx, rw, workspaceapps.IssueTokenRequest{
AppRequest: workspaceapps.Request{},
SessionToken: "user-session-token",
})
require.False(t, ok)
require.Empty(t, tokenRes)
require.True(t, rw.WasWritten())
res := rw.Result()
defer res.Body.Close()
require.Equal(t, expectedResponseStatus, res.StatusCode)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.Equal(t, expectedResponseBody, string(body))
require.EqualValues(t, called, 1)
})
}
type ResponseRecorder struct {
rw *httptest.ResponseRecorder
wasWritten atomic.Bool
}
var _ http.ResponseWriter = &ResponseRecorder{}
func newResponseRecorder() *ResponseRecorder {
return &ResponseRecorder{
rw: httptest.NewRecorder(),
}
}
func (r *ResponseRecorder) WasWritten() bool {
return r.wasWritten.Load()
}
func (r *ResponseRecorder) Result() *http.Response {
return r.rw.Result()
}
func (r *ResponseRecorder) Flush() {
r.wasWritten.Store(true)
r.rw.Flush()
}
func (r *ResponseRecorder) Header() http.Header {
// Usually when retrieving the headers for the response, it means you're
// trying to write a header.
r.wasWritten.Store(true)
return r.rw.Header()
}
func (r *ResponseRecorder) Write(b []byte) (int, error) {
r.wasWritten.Store(true)
return r.rw.Write(b)
}
func (r *ResponseRecorder) WriteHeader(statusCode int) {
r.wasWritten.Store(true)
r.rw.WriteHeader(statusCode)
}
+14 -1
View File
@@ -135,6 +135,8 @@ export type AuthorizationResponse = Record<string, boolean>
export interface BuildInfoResponse {
readonly external_url: string
readonly version: string
readonly dashboard_url: string
readonly workspace_proxy: boolean
}
// From codersdk/parameters.go
@@ -262,6 +264,12 @@ export interface CreateWorkspaceProxyRequest {
readonly wildcard_hostname: string
}
// From codersdk/workspaceproxy.go
export interface CreateWorkspaceProxyResponse {
readonly proxy: WorkspaceProxy
readonly proxy_token: string
}
// From codersdk/organizations.go
export interface CreateWorkspaceRequest {
readonly template_id: string
@@ -1218,7 +1226,6 @@ export interface WorkspaceOptions {
// From codersdk/workspaceproxy.go
export interface WorkspaceProxy {
readonly id: string
readonly organization_id: string
readonly name: string
readonly icon: string
readonly url: string
@@ -1228,6 +1235,12 @@ export interface WorkspaceProxy {
readonly deleted: boolean
}
// From codersdk/deployment.go
export interface WorkspaceProxyBuildInfo {
readonly workspace_proxy: boolean
readonly dashboard_url: string
}
// From codersdk/workspaces.go
export interface WorkspaceQuota {
readonly credits_consumed: number