From 68667323f30f398a22885e97c51cd5e565e370e1 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 20 Apr 2023 16:59:45 -0700 Subject: [PATCH] chore: support signed token query param for web terminal (#7197) * chore: add endpoint to get token for web terminal * chore: support signed token query param for web terminal --- coderd/apidoc/docs.go | 67 ++++++ coderd/apidoc/swagger.json | 58 ++++++ coderd/database/dbauthz/querier.go | 4 - coderd/database/dbauthz/system.go | 7 + coderd/database/dbfake/databasefake.go | 24 ++- coderd/database/dbfake/databasefake_test.go | 84 +++++--- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 84 +++++--- coderd/database/queries.sql.go | 22 +- coderd/database/queries/proxies.sql | 12 +- coderd/workspaceapps.go | 123 +++++++---- coderd/workspaceapps/apptest/apptest.go | 128 ++++++++---- coderd/workspaceapps/proxy.go | 2 +- coderd/workspaceapps/proxy_test.go | 7 +- coderd/workspaceapps/token.go | 25 ++- codersdk/client.go | 13 +- codersdk/client_internal_test.go | 4 +- codersdk/workspaceagents.go | 23 +++ docs/api/applications enterprise.md | 1 + docs/api/schemas.md | 30 +++ docs/manifest.json | 4 + enterprise/coderd/coderd.go | 4 + enterprise/coderd/workspaceproxy.go | 98 +++++++++ enterprise/coderd/workspaceproxy_test.go | 213 ++++++++++++++++++++ site/src/api/typesGenerated.ts | 11 + 25 files changed, 886 insertions(+), 164 deletions(-) create mode 100644 docs/api/applications enterprise.md diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a934f4f6cb..3d3fe6dbfd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -159,6 +159,48 @@ const docTemplate = `{ } } }, + "/applications/reconnecting-pty-signed-token": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Applications Enterprise" + ], + "summary": "Issue signed app token for reconnecting PTY", + "operationId": "issue-signed-app-token-for-reconnecting-pty", + "parameters": [ + { + "description": "Issue reconnecting PTY signed token request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenResponse" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/audit": { "get": { "security": [ @@ -7451,6 +7493,31 @@ const docTemplate = `{ } } }, + "codersdk.IssueReconnectingPTYSignedTokenRequest": { + "type": "object", + "required": [ + "agentID", + "url" + ], + "properties": { + "agentID": { + "type": "string", + "format": "uuid" + }, + "url": { + "description": "URL is the URL of the reconnecting-pty endpoint you are connecting to.", + "type": "string" + } + } + }, + "codersdk.IssueReconnectingPTYSignedTokenResponse": { + "type": "object", + "properties": { + "signed_token": { + "type": "string" + } + } + }, "codersdk.JobErrorCode": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index aea4a4111a..3e8fd278fc 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -131,6 +131,42 @@ } } }, + "/applications/reconnecting-pty-signed-token": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Applications Enterprise"], + "summary": "Issue signed app token for reconnecting PTY", + "operationId": "issue-signed-app-token-for-reconnecting-pty", + "parameters": [ + { + "description": "Issue reconnecting PTY signed token request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.IssueReconnectingPTYSignedTokenResponse" + } + } + }, + "x-apidocgen": { + "skip": true + } + } + }, "/audit": { "get": { "security": [ @@ -6682,6 +6718,28 @@ } } }, + "codersdk.IssueReconnectingPTYSignedTokenRequest": { + "type": "object", + "required": ["agentID", "url"], + "properties": { + "agentID": { + "type": "string", + "format": "uuid" + }, + "url": { + "description": "URL is the URL of the reconnecting-pty endpoint you are connecting to.", + "type": "string" + } + } + }, + "codersdk.IssueReconnectingPTYSignedTokenResponse": { + "type": "object", + "properties": { + "signed_token": { + "type": "string" + } + } + }, "codersdk.JobErrorCode": { "type": "string", "enum": ["MISSING_TEMPLATE_PARAMETER", "REQUIRED_TEMPLATE_VARIABLES"], diff --git a/coderd/database/dbauthz/querier.go b/coderd/database/dbauthz/querier.go index d8ed0d9a93..54207fd390 100644 --- a/coderd/database/dbauthz/querier.go +++ b/coderd/database/dbauthz/querier.go @@ -1701,10 +1701,6 @@ func (q *querier) GetWorkspaceProxyByName(ctx context.Context, name string) (dat return fetch(q.log, q.auth, q.db.GetWorkspaceProxyByName)(ctx, name) } -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) } diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index 90e3afc500..1252788f37 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -438,3 +438,10 @@ func (q *querier) InsertParameterSchema(ctx context.Context, arg database.Insert } return q.db.InsertParameterSchema(ctx, arg) } + +func (q *querier) GetWorkspaceProxyByHostname(ctx context.Context, params database.GetWorkspaceProxyByHostnameParams) (database.WorkspaceProxy, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return database.WorkspaceProxy{}, err + } + return q.db.GetWorkspaceProxyByHostname(ctx, params) +} diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index b12551e32c..581c93c9d0 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -24,7 +24,7 @@ import ( "github.com/coder/coder/coderd/util/slice" ) -var validProxyByHostnameRegex = regexp.MustCompile(`^[a-zA-Z0-9.-]+$`) +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 @@ -5142,34 +5142,36 @@ func (q *fakeQuerier) GetWorkspaceProxyByName(_ context.Context, name string) (d return database.WorkspaceProxy{}, sql.ErrNoRows } -func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, hostname string) (database.WorkspaceProxy, error) { +func (q *fakeQuerier) GetWorkspaceProxyByHostname(_ context.Context, params database.GetWorkspaceProxyByHostnameParams) (database.WorkspaceProxy, error) { q.mutex.RLock() defer q.mutex.RUnlock() // 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) { + if !validProxyByHostnameRegex.MatchString(params.Hostname) { return database.WorkspaceProxy{}, sql.ErrNoRows } // This regex matches the SQL version. - accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(hostname) + `([:/]?.)*`) + accessURLRegex := regexp.MustCompile(`[^:]*://` + regexp.QuoteMeta(params.Hostname) + `([:/]?.)*`) for _, proxy := range q.workspaceProxies { if proxy.Deleted { continue } - if accessURLRegex.MatchString(proxy.Url) { + if params.AllowAccessUrl && 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 + if params.AllowWildcardHostname { + 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, params.Hostname); ok { + return proxy, nil + } } } diff --git a/coderd/database/dbfake/databasefake_test.go b/coderd/database/dbfake/databasefake_test.go index 33a564914b..f2468d9329 100644 --- a/coderd/database/dbfake/databasefake_test.go +++ b/coderd/database/dbfake/databasefake_test.go @@ -160,44 +160,74 @@ func TestProxyByHostname(t *testing.T) { } cases := []struct { - name string - testHostname string - matchProxyName string + name string + testHostname string + allowAccessURL bool + allowWildcardHost bool + matchProxyName string }{ { - name: "NoMatch", - testHostname: "test.com", - matchProxyName: "", + name: "NoMatch", + testHostname: "test.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", }, { - name: "MatchAccessURL", - testHostname: "one.coder.com", - matchProxyName: "one", + name: "MatchAccessURL", + testHostname: "one.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "one", }, { - name: "MatchWildcard", - testHostname: "something.wildcard.one.coder.com", - matchProxyName: "one", + name: "MatchWildcard", + testHostname: "something.wildcard.one.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "one", }, { - name: "MatchSuffix", - testHostname: "something--suffix.two.coder.com", - matchProxyName: "two", + name: "MatchSuffix", + testHostname: "something--suffix.two.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "two", }, { - name: "ValidateHostname/1", - testHostname: ".*ne.coder.com", - matchProxyName: "", + name: "ValidateHostname/1", + testHostname: ".*ne.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", }, { - name: "ValidateHostname/2", - testHostname: "https://one.coder.com", - matchProxyName: "", + name: "ValidateHostname/2", + testHostname: "https://one.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", }, { - name: "ValidateHostname/3", - testHostname: "one.coder.com:8080/hello", - matchProxyName: "", + name: "ValidateHostname/3", + testHostname: "one.coder.com:8080/hello", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", + }, + { + name: "IgnoreAccessURLMatch", + testHostname: "one.coder.com", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, + { + name: "IgnoreWildcardMatch", + testHostname: "hi.wildcard.one.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", }, } @@ -206,7 +236,11 @@ func TestProxyByHostname(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() - proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname) + proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), database.GetWorkspaceProxyByHostnameParams{ + Hostname: c.testHostname, + AllowAccessUrl: c.allowAccessURL, + AllowWildcardHostname: c.allowWildcardHost, + }) if c.matchProxyName == "" { require.ErrorIs(t, err, sql.ErrNoRows) require.Empty(t, proxy) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 4d658ee72a..01b79c0ae1 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -156,7 +156,7 @@ type sqlcQuerier interface { // 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) + GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (WorkspaceProxy, error) GetWorkspaceProxyByName(ctx context.Context, name string) (WorkspaceProxy, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index e67164ef16..19c80e7d5b 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -165,44 +165,74 @@ func TestProxyByHostname(t *testing.T) { } cases := []struct { - name string - testHostname string - matchProxyName string + name string + testHostname string + allowAccessURL bool + allowWildcardHost bool + matchProxyName string }{ { - name: "NoMatch", - testHostname: "test.com", - matchProxyName: "", + name: "NoMatch", + testHostname: "test.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", }, { - name: "MatchAccessURL", - testHostname: "one.coder.com", - matchProxyName: "one", + name: "MatchAccessURL", + testHostname: "one.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "one", }, { - name: "MatchWildcard", - testHostname: "something.wildcard.one.coder.com", - matchProxyName: "one", + name: "MatchWildcard", + testHostname: "something.wildcard.one.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "one", }, { - name: "MatchSuffix", - testHostname: "something--suffix.two.coder.com", - matchProxyName: "two", + name: "MatchSuffix", + testHostname: "something--suffix.two.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "two", }, { - name: "ValidateHostname/1", - testHostname: ".*ne.coder.com", - matchProxyName: "", + name: "ValidateHostname/1", + testHostname: ".*ne.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", }, { - name: "ValidateHostname/2", - testHostname: "https://one.coder.com", - matchProxyName: "", + name: "ValidateHostname/2", + testHostname: "https://one.coder.com", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", }, { - name: "ValidateHostname/3", - testHostname: "one.coder.com:8080/hello", - matchProxyName: "", + name: "ValidateHostname/3", + testHostname: "one.coder.com:8080/hello", + allowAccessURL: true, + allowWildcardHost: true, + matchProxyName: "", + }, + { + name: "IgnoreAccessURLMatch", + testHostname: "one.coder.com", + allowAccessURL: false, + allowWildcardHost: true, + matchProxyName: "", + }, + { + name: "IgnoreWildcardMatch", + testHostname: "hi.wildcard.one.coder.com", + allowAccessURL: true, + allowWildcardHost: false, + matchProxyName: "", }, } @@ -211,7 +241,11 @@ func TestProxyByHostname(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() - proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), c.testHostname) + proxy, err := db.GetWorkspaceProxyByHostname(context.Background(), database.GetWorkspaceProxyByHostnameParams{ + Hostname: c.testHostname, + AllowAccessUrl: c.allowAccessURL, + AllowWildcardHostname: c.allowWildcardHost, + }) if c.matchProxyName == "" { require.ErrorIs(t, err, sql.ErrNoRows) require.Empty(t, proxy) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0aa9dd2e07..951a57704d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2871,27 +2871,39 @@ WHERE -- -- 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 + $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, '*', '%') + ( + $2 :: bool = true AND + url SIMILAR TO '[^:]*://' || $1 :: text || '([:/]?%)*' + ) OR + ( + $3 :: bool = true AND + $1 :: text LIKE replace(wildcard_hostname, '*', '%') + ) ) LIMIT 1 ` +type GetWorkspaceProxyByHostnameParams struct { + Hostname string `db:"hostname" json:"hostname"` + AllowAccessUrl bool `db:"allow_access_url" json:"allow_access_url"` + AllowWildcardHostname bool `db:"allow_wildcard_hostname" json:"allow_wildcard_hostname"` +} + // 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) +func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, arg GetWorkspaceProxyByHostnameParams) (WorkspaceProxy, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceProxyByHostname, arg.Hostname, arg.AllowAccessUrl, arg.AllowWildcardHostname) var i WorkspaceProxy err := row.Scan( &i.ID, diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql index b8b92f2885..faa3066a48 100644 --- a/coderd/database/queries/proxies.sql +++ b/coderd/database/queries/proxies.sql @@ -77,14 +77,20 @@ WHERE -- -- 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 + @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, '*', '%') + ( + @allow_access_url :: bool = true AND + url SIMILAR TO '[^:]*://' || @hostname :: text || '([:/]?%)*' + ) OR + ( + @allow_wildcard_hostname :: bool = true AND + @hostname :: text LIKE replace(wildcard_hostname, '*', '%') + ) ) LIMIT 1; diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index fd4d44200f..cec40eed56 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -1,15 +1,18 @@ package coderd import ( + "context" "database/sql" "fmt" "net/http" "net/url" + "strings" "time" "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/coderd/httpmw" "github.com/coder/coder/coderd/rbac" @@ -73,45 +76,28 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request }) return } - // Force the redirect URI to use the same scheme as the access URL for - // security purposes. - u.Scheme = api.AccessURL.Scheme - ok := false - if api.AppHostnameRegex != nil { - _, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, u.Host) + u.Scheme, err = api.ValidWorkspaceAppHostname(ctx, u.Host, ValidWorkspaceAppHostnameOpts{ + // Allow all hosts except primary access URL since we don't need app + // tokens on the primary dashboard URL. + AllowPrimaryAccessURL: false, + AllowPrimaryWildcard: true, + AllowProxyAccessURL: true, + AllowProxyWildcard: true, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to verify redirect_uri query parameter.", + Detail: err.Error(), + }) + return } - - // Ensure that the redirect URI is a subdomain of api.Hostname and is a - // valid app subdomain. - if !ok { - 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 + if u.Scheme == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid redirect_uri.", + Detail: "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 } // Create the application_connect-scoped API key with the same lifetime as @@ -156,3 +142,66 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request u.RawQuery = q.Encode() http.Redirect(rw, r, u.String(), http.StatusSeeOther) } + +type ValidWorkspaceAppHostnameOpts struct { + AllowPrimaryAccessURL bool + AllowPrimaryWildcard bool + AllowProxyAccessURL bool + AllowProxyWildcard bool +} + +// ValidWorkspaceAppHostname checks if the given host is a valid workspace app +// hostname based on the provided options. It returns a scheme to force on +// success. If the hostname is not valid or doesn't match, an empty string is +// returned. Any error returned is a 500 error. +// +// For hosts that match a wildcard app hostname, the scheme is forced to be the +// corresponding access URL scheme. +func (api *API) ValidWorkspaceAppHostname(ctx context.Context, host string, opts ValidWorkspaceAppHostnameOpts) (string, error) { + if opts.AllowPrimaryAccessURL && (host == api.AccessURL.Hostname() || host == api.AccessURL.Host) { + // Force the redirect URI to have the same scheme as the access URL for + // security purposes. + return api.AccessURL.Scheme, nil + } + + if opts.AllowPrimaryWildcard && api.AppHostnameRegex != nil { + _, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host) + if ok { + // Force the redirect URI to have the same scheme as the access URL + // for security purposes. + return api.AccessURL.Scheme, nil + } + } + + // Ensure that the redirect URI is a subdomain of api.Hostname and is a + // valid app subdomain. + if opts.AllowProxyAccessURL || opts.AllowProxyWildcard { + // Strip the port for the database query. + host = strings.Split(host, ":")[0] + + // nolint:gocritic // system query + systemCtx := dbauthz.AsSystemRestricted(ctx) + proxy, err := api.Database.GetWorkspaceProxyByHostname(systemCtx, database.GetWorkspaceProxyByHostnameParams{ + Hostname: host, + AllowAccessUrl: opts.AllowProxyAccessURL, + AllowWildcardHostname: opts.AllowProxyWildcard, + }) + if xerrors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", xerrors.Errorf("get workspace proxy by hostname %q: %w", host, err) + } + + proxyURL, err := url.Parse(proxy.Url) + if err != nil { + return "", xerrors.Errorf("parse proxy URL %q: %w", proxy.Url, err) + } + + // Force the redirect URI to use the same scheme as the proxy access URL + // for security purposes. + return proxyURL.Scheme, nil + } + + return "", nil +} diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index c17cc779e9..bb3a1887a6 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "nhooyr.io/websocket" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/rbac" @@ -46,43 +47,9 @@ func Run(t *testing.T, factory DeploymentFactory) { t.Skip("ConPTY appears to be inconsistent on Windows.") } - appDetails := setupProxyTest(t, nil) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - // Run the test against the path app hostname since that's where the - // reconnecting-pty proxy server we want to test is mounted. - 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() - - // First attempt to resize the TTY. - // The websocket will close if it fails! - data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ - Height: 250, - Width: 250, - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - bufRead := bufio.NewReader(conn) - - // Brief pause to reduce the likelihood that we send keystrokes while - // the shell is simultaneously sending a prompt. - time.Sleep(100 * time.Millisecond) - - data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ - Data: "echo test\r\n", - }) - require.NoError(t, err) - _, err = conn.Write(data) - require.NoError(t, err) - - expectLine := func(matcher func(string) bool) { + expectLine := func(r *bufio.Reader, matcher func(string) bool) { for { - line, err := bufRead.ReadString('\n') + line, err := r.ReadString('\n') require.NoError(t, err) if matcher(line) { break @@ -96,8 +63,93 @@ func Run(t *testing.T, factory DeploymentFactory) { return strings.Contains(line, "test") && !strings.Contains(line, "echo") } - expectLine(matchEchoCommand) - expectLine(matchEchoOutput) + t.Run("OK", func(t *testing.T) { + appDetails := setupProxyTest(t, nil) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Run the test against the path app hostname since that's where the + // reconnecting-pty proxy server we want to test is mounted. + 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() + + // First attempt to resize the TTY. + // The websocket will close if it fails! + data, err := json.Marshal(codersdk.ReconnectingPTYRequest{ + Height: 250, + Width: 250, + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + bufRead := bufio.NewReader(conn) + + // Brief pause to reduce the likelihood that we send keystrokes while + // the shell is simultaneously sending a prompt. + time.Sleep(100 * time.Millisecond) + + data, err = json.Marshal(codersdk.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = conn.Write(data) + require.NoError(t, err) + + expectLine(bufRead, matchEchoCommand) + expectLine(bufRead, matchEchoOutput) + }) + + t.Run("SignedTokenQueryParameter", func(t *testing.T) { + t.Parallel() + + appDetails := setupProxyTest(t, nil) + if appDetails.AppHostIsPrimary { + t.Skip("Tickets are not used for terminal requests on the primary.") + } + + u := *appDetails.PathAppBaseURL + if u.Scheme == "http" { + u.Scheme = "ws" + } else { + u.Scheme = "wss" + } + u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", appDetails.Agent.ID.String()) + + ctx := testutil.Context(t, testutil.WaitLong) + issueRes, err := appDetails.SDKClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: appDetails.Agent.ID, + }) + require.NoError(t, err) + + // Try to connect to the endpoint with the signed token and no other + // authentication. + q := u.Query() + q.Set("reconnect", uuid.NewString()) + q.Set("height", strconv.Itoa(24)) + q.Set("width", strconv.Itoa(80)) + q.Set("command", `/bin/sh -c "echo test"`) + q.Set(codersdk.SignedAppTokenQueryParameter, issueRes.SignedToken) + u.RawQuery = q.Encode() + + //nolint:bodyclose + wsConn, res, err := websocket.Dial(ctx, u.String(), nil) + if !assert.NoError(t, err) { + dump, err := httputil.DumpResponse(res, true) + if err == nil { + t.Log(string(dump)) + } + return + } + defer wsConn.Close(websocket.StatusNormalClosure, "") + conn := websocket.NetConn(ctx, wsConn, websocket.MessageBinary) + bufRead := bufio.NewReader(conn) + + expectLine(bufRead, matchEchoOutput) + }) }) t.Run("WorkspaceAppsProxyPath", func(t *testing.T) { diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index d0c5938014..5ee0d46715 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -594,7 +594,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { BasePath: r.URL.Path, AgentNameOrID: chi.URLParam(r, "workspaceagent"), }, - AppPath: r.URL.Path, + AppPath: "", AppQuery: "", }) if !ok { diff --git a/coderd/workspaceapps/proxy_test.go b/coderd/workspaceapps/proxy_test.go index a497ed1570..5c71f15ffa 100644 --- a/coderd/workspaceapps/proxy_test.go +++ b/coderd/workspaceapps/proxy_test.go @@ -1,8 +1,3 @@ package workspaceapps_test -// NOTE: for now, app proxying tests are still in their old locations, pending -// being moved to their own package. -// -// See: -// - coderd/workspaceapps_test.go -// - coderd/workspaceagents_test.go (for PTY) +// App tests can be found in the apptest package. diff --git a/coderd/workspaceapps/token.go b/coderd/workspaceapps/token.go index 77ee394a99..3811602a23 100644 --- a/coderd/workspaceapps/token.go +++ b/coderd/workspaceapps/token.go @@ -224,13 +224,30 @@ func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) { return payload.APIKey, nil } +// FromRequest returns the signed token from the request, if it exists and is +// valid. The caller must check that the token matches the request. func FromRequest(r *http.Request, key SecurityKey) (*SignedToken, bool) { - // Get the existing token from the request. - tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie) - if err == nil { - token, err := key.VerifySignedToken(tokenCookie.Value) + // Get the token string from the request. We usually use a cookie for this, + // but for web terminal we also support a query parameter to support + // cross-domain terminal access. + tokenStr := "" + tokenCookie, cookieErr := r.Cookie(codersdk.DevURLSignedAppTokenCookie) + if cookieErr == nil { + tokenStr = tokenCookie.Value + } else { + tokenStr = r.URL.Query().Get(codersdk.SignedAppTokenQueryParameter) + } + + if tokenStr != "" { + token, err := key.VerifySignedToken(tokenStr) if err == nil { req := token.Request.Normalize() + if cookieErr != nil && req.AccessMethod != AccessMethodTerminal { + // The request must be a terminal request if we're using a + // query parameter. + return nil, false + } + err := req.Validate() if err == nil { // The request has a valid signed app token, which is a valid diff --git a/codersdk/client.go b/codersdk/client.go index 210d5898f1..a558c3d4f5 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -47,6 +47,15 @@ const ( // token. //nolint:gosec DevURLSignedAppTokenCookie = "coder_devurl_signed_app_token" + // SignedAppTokenQueryParameter is the name of the query parameter that + // stores a temporary JWT that can be used to authenticate instead of the + // session token. This is only acceptable on reconnecting-pty requests, not + // apps. + // + // It has a random suffix to avoid conflict with user query parameters on + // apps. + //nolint:gosec + SignedAppTokenQueryParameter = "coder_signed_app_token_23db1dde" // BypassRatelimitHeader is the custom header to use to bypass ratelimits. // Only owners can bypass rate limits. This is typically used for scale testing. @@ -289,8 +298,8 @@ func ReadBodyAsError(res *http.Response) error { mimeType := parseMimeType(contentType) if mimeType != "application/json" { - if len(resp) > 1024 { - resp = append(resp[:1024], []byte("...")...) + if len(resp) > 2048 { + resp = append(resp[:2048], []byte("...")...) } if len(resp) == 0 { resp = []byte("no response body") diff --git a/codersdk/client_internal_test.go b/codersdk/client_internal_test.go index 3dea65a24a..9ac56ae9c2 100644 --- a/codersdk/client_internal_test.go +++ b/codersdk/client_internal_test.go @@ -174,7 +174,7 @@ func Test_readBodyAsError(t *testing.T) { } longResponse := "" - for i := 0; i < 2000; i++ { + for i := 0; i < 4000; i++ { longResponse += "a" } @@ -258,7 +258,7 @@ func Test_readBodyAsError(t *testing.T) { assert.Contains(t, sdkErr.Response.Message, "unexpected non-JSON response") - expected := longResponse[0:1024] + "..." + expected := longResponse[0:2048] + "..." assert.Equal(t, expected, sdkErr.Response.Detail) }, }, diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 80749fb726..87a13d45de 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -362,6 +362,29 @@ func (c *Client) WorkspaceAgent(ctx context.Context, id uuid.UUID) (WorkspaceAge return workspaceAgent, json.NewDecoder(res.Body).Decode(&workspaceAgent) } +type IssueReconnectingPTYSignedTokenRequest struct { + // URL is the URL of the reconnecting-pty endpoint you are connecting to. + URL string `json:"url" validate:"required"` + AgentID uuid.UUID `json:"agentID" format:"uuid" validate:"required"` +} + +type IssueReconnectingPTYSignedTokenResponse struct { + SignedToken string `json:"signed_token"` +} + +func (c *Client) IssueReconnectingPTYSignedToken(ctx context.Context, req IssueReconnectingPTYSignedTokenRequest) (IssueReconnectingPTYSignedTokenResponse, error) { + res, err := c.Request(ctx, http.MethodPost, "/api/v2/applications/reconnecting-pty-signed-token", req) + if err != nil { + return IssueReconnectingPTYSignedTokenResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return IssueReconnectingPTYSignedTokenResponse{}, ReadBodyAsError(res) + } + var resp IssueReconnectingPTYSignedTokenResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + // WorkspaceAgentReconnectingPTY spawns a PTY that reconnects using the token provided. // It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON. // Responses are PTY output that can be rendered. diff --git a/docs/api/applications enterprise.md b/docs/api/applications enterprise.md new file mode 100644 index 0000000000..ceb96d41a4 --- /dev/null +++ b/docs/api/applications enterprise.md @@ -0,0 +1 @@ +# Applications Enterprise diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5f55f96142..2c6ea72d3f 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2655,6 +2655,36 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `threshold` | integer | false | | Threshold specifies the number of consecutive failed health checks before returning "unhealthy". | | `url` | string | false | | URL specifies the endpoint to check for the app health. | +## codersdk.IssueReconnectingPTYSignedTokenRequest + +```json +{ + "agentID": "bc282582-04f9-45ce-b904-3e3bfab66958", + "url": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------- | ------ | -------- | ------------ | ---------------------------------------------------------------------- | +| `agentID` | string | true | | | +| `url` | string | true | | URL is the URL of the reconnecting-pty endpoint you are connecting to. | + +## codersdk.IssueReconnectingPTYSignedTokenResponse + +```json +{ + "signed_token": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------ | -------- | ------------ | ----------- | +| `signed_token` | string | false | | | + ## codersdk.JobErrorCode ```json diff --git a/docs/manifest.json b/docs/manifest.json index 4c4fb7d187..f681b509a0 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -406,6 +406,10 @@ "title": "Applications", "path": "./api/applications.md" }, + { + "title": "Applications Enterprise", + "path": "./api/applications enterprise.md" + }, { "title": "Audit", "path": "./api/audit.md" diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5123100983..287e9b38db 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -81,6 +81,10 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Get("/", api.licenses) r.Delete("/{id}", api.deleteLicense) }) + r.Route("/applications/reconnecting-pty-signed-token", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Post("/", api.reconnectingPTYSignedToken) + }) r.Route("/workspaceproxies", func(r chi.Router) { r.Use( api.moonsEnabledMW, diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 150c5b4f45..d983c7988d 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -10,10 +10,12 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + agpl "github.com/coder/coder/coderd" "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/workspaceapps" "github.com/coder/coder/codersdk" "github.com/coder/coder/cryptorand" @@ -314,3 +316,99 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request) AppSecurityKey: api.AppSecurityKey.String(), }) } + +// reconnectingPTYSignedToken issues a signed app token for use when connecting +// to the reconnecting PTY websocket on an external workspace proxy. This is set +// by the client as a query parameter when connecting. +// +// @Summary Issue signed app token for reconnecting PTY +// @ID issue-signed-app-token-for-reconnecting-pty +// @Security CoderSessionToken +// @Tags Applications Enterprise +// @Accept json +// @Produce json +// @Param request body codersdk.IssueReconnectingPTYSignedTokenRequest true "Issue reconnecting PTY signed token request" +// @Success 200 {object} codersdk.IssueReconnectingPTYSignedTokenResponse +// @Router /applications/reconnecting-pty-signed-token [post] +// @x-apidocgen {"skip": true} +func (api *API) reconnectingPTYSignedToken(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + if !api.Authorize(r, rbac.ActionCreate, apiKey) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.IssueReconnectingPTYSignedTokenRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + u, err := url.Parse(req.URL) + if err == nil && u.Scheme != "ws" && u.Scheme != "wss" { + err = xerrors.Errorf("invalid URL scheme %q, expected 'ws' or 'wss'", u.Scheme) + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid URL.", + Detail: err.Error(), + }) + return + } + + // Assert the URL is a valid reconnecting-pty URL. + expectedPath := fmt.Sprintf("/api/v2/workspaceagents/%s/pty", req.AgentID.String()) + if u.Path != expectedPath { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid URL path.", + Detail: "The provided URL is not a valid reconnecting PTY endpoint URL.", + }) + return + } + + scheme, err := api.AGPL.ValidWorkspaceAppHostname(ctx, u.Host, agpl.ValidWorkspaceAppHostnameOpts{ + // Only allow the proxy access URL as a hostname since we don't need a + // ticket for the primary dashboard URL terminal. + AllowPrimaryAccessURL: false, + AllowPrimaryWildcard: false, + AllowProxyAccessURL: true, + AllowProxyWildcard: false, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to verify hostname in URL.", + Detail: err.Error(), + }) + return + } + if scheme == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid hostname in URL.", + Detail: "The hostname must be the primary wildcard app hostname, a workspace proxy access URL or a workspace proxy wildcard app hostname.", + }) + return + } + + _, tokenStr, ok := api.AGPL.WorkspaceAppsProvider.Issue(ctx, rw, r, workspaceapps.IssueTokenRequest{ + AppRequest: workspaceapps.Request{ + AccessMethod: workspaceapps.AccessMethodTerminal, + BasePath: u.Path, + AgentNameOrID: req.AgentID.String(), + }, + SessionToken: httpmw.APITokenFromRequest(r), + // The following fields aren't required as long as the request is authed + // with a valid API key. + PathAppBaseURL: "", + AppHostname: "", + // The following fields are empty for terminal apps. + AppPath: "", + AppQuery: "", + }) + if !ok { + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.IssueReconnectingPTYSignedTokenResponse{ + SignedToken: tokenStr, + }) +} diff --git a/enterprise/coderd/workspaceproxy_test.go b/enterprise/coderd/workspaceproxy_test.go index 71b85ddda2..b3bd6c8727 100644 --- a/enterprise/coderd/workspaceproxy_test.go +++ b/enterprise/coderd/workspaceproxy_test.go @@ -1,8 +1,11 @@ package coderd_test import ( + "fmt" + "net/http" "net/http/httptest" "net/http/httputil" + "net/url" "testing" "github.com/google/uuid" @@ -206,3 +209,213 @@ func TestIssueSignedAppToken(t *testing.T) { } }) } + +func TestReconnectingPTYSignedToken(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 + agentID := build.Resources[0].Agents[0].ID + 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) + + u, err := url.Parse(proxyRes.Proxy.URL) + require.NoError(t, err) + if u.Scheme == "https" { + u.Scheme = "wss" + } else { + u.Scheme = "ws" + } + u.Path = fmt.Sprintf("/api/v2/workspaceagents/%s/pty", agentID.String()) + + t.Run("Validate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: "", + AgentID: uuid.Nil, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("BadURL", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: ":", + AgentID: agentID, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Response.Message, "Invalid URL") + }) + + t.Run("BadURL", func(t *testing.T) { + t.Parallel() + + u := *u + u.Scheme = "ftp" + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: agentID, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Response.Message, "Invalid URL") + require.Contains(t, sdkErr.Response.Detail, "scheme") + }) + + t.Run("BadURLPath", func(t *testing.T) { + t.Parallel() + + u := *u + u.Path = "/hello" + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: agentID, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Response.Message, "Invalid URL") + require.Contains(t, sdkErr.Response.Detail, "The provided URL is not a valid reconnecting PTY endpoint URL") + }) + + t.Run("BadHostname", func(t *testing.T) { + t.Parallel() + + u := *u + u.Host = "badhostname.com" + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: agentID, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Response.Message, "Invalid hostname in URL") + }) + + t.Run("NoToken", func(t *testing.T) { + t.Parallel() + + unauthedClient := codersdk.New(client.URL) + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := unauthedClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: agentID, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode()) + }) + + t.Run("NoPermissions", func(t *testing.T) { + t.Parallel() + + userClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := userClient.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: agentID, + }) + require.Error(t, err) + require.Empty(t, res) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + res, err := client.IssueReconnectingPTYSignedToken(ctx, codersdk.IssueReconnectingPTYSignedTokenRequest{ + URL: u.String(), + AgentID: agentID, + }) + require.NoError(t, err) + require.NotEmpty(t, res.SignedToken) + + // The token is validated in the apptest suite, so we don't need to + // validate it here. + }) +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6917b89456..c29ac40240 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -466,6 +466,17 @@ export interface Healthcheck { readonly threshold: number } +// From codersdk/workspaceagents.go +export interface IssueReconnectingPTYSignedTokenRequest { + readonly url: string + readonly agentID: string +} + +// From codersdk/workspaceagents.go +export interface IssueReconnectingPTYSignedTokenResponse { + readonly signed_token: string +} + // From codersdk/licenses.go export interface License { readonly id: number