feat: use app tickets for web terminal (#6628)

This commit is contained in:
Dean Sheather
2023-03-31 00:24:51 +11:00
committed by GitHub
parent a07209efa1
commit 665b84de0d
18 changed files with 1011 additions and 403 deletions

View File

@ -5,11 +5,9 @@ import (
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog"
@ -73,7 +71,8 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
if err == nil {
ticket, err := p.ParseTicket(ticketCookie.Value)
if err == nil {
if ticket.MatchesRequest(appReq) {
err := ticket.Request.Validate()
if err == nil && ticket.MatchesRequest(appReq) {
// The request has a ticket, which is a valid ticket signed by
// us, and matches the app that the user was trying to access.
return &ticket, true
@ -85,11 +84,7 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
// session token, validate auth and access to the app, then generate a new
// ticket.
ticket := Ticket{
AccessMethod: appReq.AccessMethod,
UsernameOrID: appReq.UsernameOrID,
WorkspaceNameOrID: appReq.WorkspaceNameOrID,
AgentNameOrID: appReq.AgentNameOrID,
AppSlugOrPort: appReq.AppSlugOrPort,
Request: appReq,
}
// We use the regular API apiKey extraction middleware fn here to avoid any
@ -109,167 +104,25 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
return nil, false
}
// Get user.
var (
user database.User
userErr error
)
if userID, uuidErr := uuid.Parse(appReq.UsernameOrID); uuidErr == nil {
user, userErr = p.Database.GetUserByID(dangerousSystemCtx, userID)
} else {
user, userErr = p.Database.GetUserByEmailOrUsername(dangerousSystemCtx, database.GetUserByEmailOrUsernameParams{
Username: appReq.UsernameOrID,
})
}
if xerrors.Is(userErr, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("user %q not found", appReq.UsernameOrID))
// Lookup workspace app details from DB.
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
if xerrors.Is(err, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, &appReq, err.Error())
return nil, false
} else if userErr != nil {
p.writeWorkspaceApp500(rw, r, &appReq, userErr, "get user")
} else if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get app details from database")
return nil, false
}
ticket.UserID = user.ID
ticket.UserID = dbReq.User.ID
ticket.WorkspaceID = dbReq.Workspace.ID
ticket.AgentID = dbReq.Agent.ID
ticket.AppURL = dbReq.AppURL
// Get workspace.
var (
workspace database.Workspace
workspaceErr error
)
if workspaceID, uuidErr := uuid.Parse(appReq.WorkspaceNameOrID); uuidErr == nil {
workspace, workspaceErr = p.Database.GetWorkspaceByID(dangerousSystemCtx, workspaceID)
} else {
workspace, workspaceErr = p.Database.GetWorkspaceByOwnerIDAndName(dangerousSystemCtx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
Name: appReq.WorkspaceNameOrID,
Deleted: false,
})
}
if xerrors.Is(workspaceErr, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("workspace %q not found", appReq.WorkspaceNameOrID))
return nil, false
} else if workspaceErr != nil {
p.writeWorkspaceApp500(rw, r, &appReq, workspaceErr, "get workspace")
return nil, false
}
ticket.WorkspaceID = workspace.ID
// Get agent.
var (
agent database.WorkspaceAgent
agentErr error
trustAgent = false
)
if agentID, uuidErr := uuid.Parse(appReq.AgentNameOrID); uuidErr == nil {
agent, agentErr = p.Database.GetWorkspaceAgentByID(dangerousSystemCtx, agentID)
} else {
build, err := p.Database.GetLatestWorkspaceBuildByWorkspaceID(dangerousSystemCtx, workspace.ID)
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get latest workspace build")
return nil, false
}
// nolint:gocritic // We need to fetch the agent to authenticate the request. This is a system function.
resources, err := p.Database.GetWorkspaceResourcesByJobID(dangerousSystemCtx, build.JobID)
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get workspace resources")
return nil, false
}
resourcesIDs := []uuid.UUID{}
for _, resource := range resources {
resourcesIDs = append(resourcesIDs, resource.ID)
}
// nolint:gocritic // We need to fetch the agent to authenticate the request. This is a system function.
agents, err := p.Database.GetWorkspaceAgentsByResourceIDs(dangerousSystemCtx, resourcesIDs)
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get workspace agents")
return nil, false
}
if appReq.AgentNameOrID == "" {
if len(agents) != 1 {
p.writeWorkspaceApp404(rw, r, &appReq, "no agent specified, but multiple exist in workspace")
return nil, false
}
agent = agents[0]
trustAgent = true
} else {
for _, a := range agents {
if a.Name == appReq.AgentNameOrID {
agent = a
trustAgent = true
break
}
}
}
if agent.ID == uuid.Nil {
agentErr = sql.ErrNoRows
}
}
if xerrors.Is(agentErr, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("agent %q not found", appReq.AgentNameOrID))
return nil, false
} else if agentErr != nil {
p.writeWorkspaceApp500(rw, r, &appReq, agentErr, "get agent")
return nil, false
}
// Verify the agent belongs to the workspace.
if !trustAgent {
//nolint:gocritic // We need to fetch the agent to authenticate the request. This is a system function.
agentResource, err := p.Database.GetWorkspaceResourceByID(dangerousSystemCtx, agent.ResourceID)
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get agent resource")
return nil, false
}
build, err := p.Database.GetWorkspaceBuildByJobID(dangerousSystemCtx, agentResource.JobID)
if err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get agent workspace build")
return nil, false
}
if build.WorkspaceID != workspace.ID {
p.writeWorkspaceApp404(rw, r, &appReq, "agent does not belong to workspace")
return nil, false
}
}
ticket.AgentID = agent.ID
// Get app.
appSharingLevel := database.AppSharingLevelOwner
portUint, portUintErr := strconv.ParseUint(appReq.AppSlugOrPort, 10, 16)
if appReq.AccessMethod == AccessMethodSubdomain && portUintErr == nil {
// If the app slug is a port number, then route to the port as an
// "anonymous app". We only support HTTP for port-based URLs.
//
// This is only supported for subdomain-based applications.
ticket.AppURL = fmt.Sprintf("http://127.0.0.1:%d", portUint)
} else {
app, ok := p.lookupWorkspaceApp(rw, r, agent.ID, appReq.AppSlugOrPort)
if !ok {
return nil, false
}
if !app.Url.Valid {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application %q does not have a URL set.", app.Slug),
RetryEnabled: true,
DashboardURL: p.AccessURL.String(),
})
return nil, false
}
if app.SharingLevel != "" {
appSharingLevel = app.SharingLevel
}
ticket.AppURL = app.Url.String
}
// TODO(@deansheather): return an error if the agent is offline or the app
// is not running.
// Verify the user has access to the app.
authed, ok := p.fetchWorkspaceApplicationAuth(rw, r, authz, appReq.AccessMethod, workspace, appSharingLevel)
authed, ok := p.verifyAuthz(rw, r, authz, dbReq)
if !ok {
return nil, false
}
@ -282,7 +135,12 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
// Redirect to login as they don't have permission to access the app
// and they aren't signed in.
if appReq.AccessMethod == AccessMethodSubdomain {
switch appReq.AccessMethod {
case AccessMethodPath:
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)
@ -294,12 +152,26 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
} else {
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
case AccessMethodTerminal:
// Return an error.
httpapi.ResourceNotFound(rw)
}
return nil, false
}
// Check that the agent is online.
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
p.writeWorkspaceAppOffline(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 {
p.writeWorkspaceAppOffline(rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
return nil, false
}
// As a sanity check, ensure the ticket we just made is valid for this
// request.
if !ticket.MatchesRequest(appReq) {
@ -329,35 +201,8 @@ func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appRe
return &ticket, true
}
// lookupWorkspaceApp looks up the workspace application by slug in the given
// agent and returns it. If the application is not found or there was a server
// error while looking it up, an HTML error page is returned and false is
// returned so the caller can return early.
func (p *Provider) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appSlug string) (database.WorkspaceApp, bool) {
// nolint:gocritic // We need to fetch the workspace app to authorize the request.
app, err := p.Database.GetWorkspaceAppByAgentIDAndSlug(dbauthz.AsSystemRestricted(r.Context()), database.GetWorkspaceAppByAgentIDAndSlugParams{
AgentID: agentID,
Slug: appSlug,
})
if xerrors.Is(err, sql.ErrNoRows) {
p.writeWorkspaceApp404(rw, r, nil, "application not found in agent by slug")
return database.WorkspaceApp{}, false
}
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "Could not fetch workspace application: " + err.Error(),
RetryEnabled: true,
DashboardURL: p.AccessURL.String(),
})
return database.WorkspaceApp{}, false
}
return app, true
}
func (p *Provider) authorizeWorkspaceApp(ctx context.Context, roles *httpmw.Authorization, accessMethod AccessMethod, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) {
func (p *Provider) authorizeRequest(ctx context.Context, roles *httpmw.Authorization, dbReq *databaseRequest) (bool, error) {
accessMethod := dbReq.AccessMethod
if accessMethod == "" {
accessMethod = AccessMethodPath
}
@ -369,6 +214,7 @@ func (p *Provider) authorizeWorkspaceApp(ctx context.Context, roles *httpmw.Auth
//
// Site owners are blocked from accessing path-based apps unless the
// Dangerous.AllowPathAppSiteOwnerAccess flag is enabled in the check below.
sharingLevel := dbReq.AppSharingLevel
if isPathApp && !p.DeploymentValues.Dangerous.AllowPathAppSharing.Value() {
sharingLevel = database.AppSharingLevelOwner
}
@ -389,18 +235,33 @@ func (p *Provider) authorizeWorkspaceApp(ctx context.Context, roles *httpmw.Auth
// workspaces owned by different users.
if isPathApp &&
sharingLevel == database.AppSharingLevelOwner &&
workspace.OwnerID.String() != roles.Actor.ID &&
dbReq.Workspace.OwnerID.String() != roles.Actor.ID &&
!p.DeploymentValues.Dangerous.AllowPathAppSiteOwnerAccess.Value() {
return false, nil
}
// Figure out which RBAC resource to check. For terminals we use execution
// instead of application connect.
var (
rbacAction rbac.Action = rbac.ActionCreate
rbacResource rbac.Object = dbReq.Workspace.ApplicationConnectRBAC()
// rbacResourceOwned is for the level "authenticated". We still need to
// make sure the API key has permissions to connect to the actor's own
// workspace. Scopes would prevent this.
rbacResourceOwned rbac.Object = rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.Actor.ID)
)
if dbReq.AccessMethod == AccessMethodTerminal {
rbacResource = dbReq.Workspace.ExecutionRBAC()
rbacResourceOwned = rbac.ResourceWorkspaceExecution.WithOwner(roles.Actor.ID)
}
// Do a standard RBAC check. This accounts for share level "owner" and any
// other RBAC rules that may be in place.
//
// Regardless of share level or whether it's enabled or not, the owner of
// the workspace can always access applications (as long as their API key's
// scope allows it).
err := p.Authorizer.Authorize(ctx, roles.Actor, rbac.ActionCreate, workspace.ApplicationConnectRBAC())
err := p.Authorizer.Authorize(ctx, roles.Actor, rbacAction, rbacResource)
if err == nil {
return true, nil
}
@ -411,19 +272,16 @@ func (p *Provider) authorizeWorkspaceApp(ctx context.Context, roles *httpmw.Auth
// Owners can always access their own apps according to RBAC rules, so
// they have already been returned from this function.
case database.AppSharingLevelAuthenticated:
// The user is authenticated at this point, but we need to make sure
// that they have ApplicationConnect permissions to their own
// workspaces. This ensures that the key's scope has permission to
// connect to workspace apps.
object := rbac.ResourceWorkspaceApplicationConnect.WithOwner(roles.Actor.ID)
err := p.Authorizer.Authorize(ctx, roles.Actor, rbac.ActionCreate, object)
// Check with the owned resource to ensure the API key has permissions
// to connect to the actor's own workspace. This enforces scopes.
err := p.Authorizer.Authorize(ctx, roles.Actor, rbacAction, rbacResourceOwned)
if err == nil {
return true, nil
}
case database.AppSharingLevelPublic:
// We don't really care about scopes and stuff if it's public anyways.
// Someone with a restricted-scope API key could just not submit the
// API key cookie in the request and access the page.
// Someone with a restricted-scope API key could just not submit the API
// key cookie in the request and access the page.
return true, nil
}
@ -431,12 +289,12 @@ func (p *Provider) authorizeWorkspaceApp(ctx context.Context, roles *httpmw.Auth
return false, nil
}
// fetchWorkspaceApplicationAuth authorizes the user using api.Authorizer for a
// verifyAuthz authorizes the user using api.Authorizer for a
// given app share level in the given workspace. The user's authorization status
// is returned. If a server error occurs, a HTML error page is rendered and
// false is returned so the caller can return early.
func (p *Provider) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, authz *httpmw.Authorization, accessMethod AccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) {
ok, err := p.authorizeWorkspaceApp(r.Context(), authz, accessMethod, appSharingLevel, workspace)
func (p *Provider) verifyAuthz(rw http.ResponseWriter, r *http.Request, authz *httpmw.Authorization, dbReq *databaseRequest) (authed bool, ok bool) {
ok, err := p.authorizeRequest(r.Context(), authz, dbReq)
if err != nil {
p.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err))
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
@ -503,3 +361,27 @@ func (p *Provider) writeWorkspaceApp500(rw http.ResponseWriter, r *http.Request,
DashboardURL: p.AccessURL.String(),
})
}
// writeWorkspaceAppOffline writes a HTML 502 error page for a workspace app. If
// appReq is not nil, it will be used to log the request details at debug level.
func (p *Provider) writeWorkspaceAppOffline(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
if appReq != nil {
slog.Helper()
p.Logger.Debug(r.Context(),
"workspace app unavailable: "+msg,
slog.F("username_or_id", appReq.UsernameOrID),
slog.F("workspace_and_agent", appReq.WorkspaceAndAgent),
slog.F("workspace_name_or_id", appReq.WorkspaceNameOrID),
slog.F("agent_name_or_id", appReq.AgentNameOrID),
slog.F("app_slug_or_port", appReq.AppSlugOrPort),
)
}
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadGateway,
Title: "Application Unavailable",
Description: msg,
RetryEnabled: true,
DashboardURL: p.AccessURL.String(),
})
}

View File

@ -3,11 +3,13 @@ package workspaceapps_test
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"testing"
"time"
@ -36,6 +38,11 @@ func Test_ResolveRequest(t *testing.T) {
appNameAuthed = "app-authed"
appNamePublic = "app-public"
appNameInvalidURL = "app-invalid-url"
appNameUnhealthy = "app-unhealthy"
// This agent will never connect, so it will never become "connected".
agentNameUnhealthy = "agent-unhealthy"
appNameAgentUnhealthy = "app-agent-unhealthy"
// This is not a valid URL we listen on in the test, but it needs to be
// set to a value.
@ -43,6 +50,13 @@ func Test_ResolveRequest(t *testing.T) {
)
allApps := []string{appNameOwner, appNameAuthed, appNamePublic}
// Start a listener for a server that always responds with 500 for the
// unhealthy app.
unhealthySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("unhealthy"))
}))
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.DisablePathApps = false
deploymentValues.Dangerous.AllowPathAppSharing = true
@ -86,39 +100,67 @@ func Test_ResolveRequest(t *testing.T) {
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: agentName,
Auth: &proto.Agent_Token{
Token: agentAuthToken,
},
Apps: []*proto.App{
{
Slug: appNameOwner,
DisplayName: appNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: agentName,
Auth: &proto.Agent_Token{
Token: agentAuthToken,
},
{
Slug: appNameAuthed,
DisplayName: appNameAuthed,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Slug: appNamePublic,
DisplayName: appNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
{
Slug: appNameInvalidURL,
DisplayName: appNameInvalidURL,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: "test:path/to/app",
Apps: []*proto.App{
{
Slug: appNameOwner,
DisplayName: appNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
{
Slug: appNameAuthed,
DisplayName: appNameAuthed,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Slug: appNamePublic,
DisplayName: appNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
{
Slug: appNameInvalidURL,
DisplayName: appNameInvalidURL,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: "test:path/to/app",
},
{
Slug: appNameUnhealthy,
DisplayName: appNameUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Healthcheck: &proto.Healthcheck{
Url: unhealthySrv.URL,
Interval: 1,
Threshold: 1,
},
},
},
},
}},
{
Id: uuid.NewString(),
Name: agentNameUnhealthy,
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
Apps: []*proto.App{
{
Slug: appNameAgentUnhealthy,
DisplayName: appNameAgentUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
},
},
},
}},
},
},
@ -138,7 +180,7 @@ func Test_ResolveRequest(t *testing.T) {
t.Cleanup(func() {
_ = agentCloser.Close()
})
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID, agentName)
agentID := uuid.Nil
for _, resource := range resources {
@ -205,16 +247,12 @@ func Test_ResolveRequest(t *testing.T) {
_ = w.Body.Close()
require.Equal(t, &workspaceapps.Ticket{
AccessMethod: req.AccessMethod,
UsernameOrID: req.UsernameOrID,
WorkspaceNameOrID: req.WorkspaceNameOrID,
AgentNameOrID: req.AgentNameOrID,
AppSlugOrPort: req.AppSlugOrPort,
Expiry: ticket.Expiry, // ignored to avoid flakiness
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
Request: req,
Expiry: ticket.Expiry, // ignored to avoid flakiness
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}, ticket)
require.NotZero(t, ticket.Expiry)
require.InDelta(t, time.Now().Add(workspaceapps.TicketExpiry).Unix(), ticket.Expiry, time.Minute.Seconds())
@ -339,7 +377,7 @@ func Test_ResolveRequest(t *testing.T) {
ok bool
}{
{
name: "WorkspaecOnly",
name: "WorkspaceOnly",
workspaceAndAgent: workspace.Name,
workspace: workspace.Name,
agent: "",
@ -423,17 +461,20 @@ func Test_ResolveRequest(t *testing.T) {
t.Parallel()
badTicket := workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNamePublic,
Expiry: time.Now().Add(time.Minute).Unix(),
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNamePublic,
},
Expiry: time.Now().Add(time.Minute).Unix(),
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}
badTicketStr, err := api.WorkspaceAppsProvider.GenerateTicket(badTicket)
require.NoError(t, err)
@ -510,7 +551,7 @@ func Test_ResolveRequest(t *testing.T) {
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
@ -519,6 +560,30 @@ func Test_ResolveRequest(t *testing.T) {
require.Equal(t, "http://127.0.0.1:9090", ticket.AppURL)
})
t.Run("Terminal", func(t *testing.T) {
t.Parallel()
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/app",
AgentNameOrID: agentID.String(),
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.True(t, ok)
require.Equal(t, req.AccessMethod, ticket.AccessMethod)
require.Equal(t, req.BasePath, ticket.BasePath)
require.Empty(t, ticket.UsernameOrID)
require.Empty(t, ticket.WorkspaceNameOrID)
require.Equal(t, req.AgentNameOrID, ticket.Request.AgentNameOrID)
require.Empty(t, ticket.AppSlugOrPort)
require.Empty(t, ticket.AppURL)
})
t.Run("InsufficientPermissions", func(t *testing.T) {
t.Parallel()
@ -599,4 +664,89 @@ func Test_ResolveRequest(t *testing.T) {
require.Equal(t, "app.com", redirectURI.Host)
require.Equal(t, "/some-path", redirectURI.Path)
})
t.Run("UnhealthyAgent", func(t *testing.T) {
t.Parallel()
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentNameUnhealthy,
AppSlugOrPort: appNameAgentUnhealthy,
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.False(t, ok, "request succeeded even though agent is not connected")
require.Nil(t, ticket)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusBadGateway, w.StatusCode)
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
bodyStr := string(body)
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
// It'll either be "connecting" or "disconnected". Both are OK for this
// test.
require.Contains(t, bodyStr, `Agent state is "`)
})
t.Run("UnhealthyApp", func(t *testing.T) {
t.Parallel()
require.Eventually(t, func() bool {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
agent, err := client.WorkspaceAgent(ctx, agentID)
if err != nil {
t.Log("could not get agent", err)
return false
}
for _, app := range agent.Apps {
if app.Slug == appNameUnhealthy {
t.Log("app is", app.Health)
return app.Health == codersdk.WorkspaceAppHealthUnhealthy
}
}
t.Log("could not find app")
return false
}, testutil.WaitLong, testutil.IntervalFast, "wait for app to become unhealthy")
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameUnhealthy,
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.False(t, ok, "request succeeded even though app is unhealthy")
require.Nil(t, ticket)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusBadGateway, w.StatusCode)
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
bodyStr := string(body)
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
require.Contains(t, bodyStr, `App health is "unhealthy"`)
})
}

View File

@ -2,6 +2,7 @@ package workspaceapps
import (
"net/url"
"time"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
@ -16,26 +17,32 @@ import (
type Provider struct {
Logger slog.Logger
AccessURL *url.URL
Authorizer rbac.Authorizer
Database database.Store
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
TicketSigningKey []byte
AccessURL *url.URL
Authorizer rbac.Authorizer
Database database.Store
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
WorkspaceAgentInactiveTimeout time.Duration
TicketSigningKey []byte
}
func New(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, ticketSigningKey []byte) *Provider {
func New(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, ticketSigningKey []byte) *Provider {
if len(ticketSigningKey) != 64 {
panic("ticket signing key must be 64 bytes")
}
if workspaceAgentInactiveTimeout == 0 {
workspaceAgentInactiveTimeout = 1 * time.Minute
}
return &Provider{
Logger: log,
AccessURL: accessURL,
Authorizer: authz,
Database: db,
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
TicketSigningKey: ticketSigningKey,
Logger: log,
AccessURL: accessURL,
Authorizer: authz,
Database: db,
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
TicketSigningKey: ticketSigningKey,
}
}

View File

@ -1,10 +1,17 @@
package workspaceapps
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"golang.org/x/xerrors"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
)
@ -13,31 +20,55 @@ type AccessMethod string
const (
AccessMethodPath AccessMethod = "path"
AccessMethodSubdomain AccessMethod = "subdomain"
// AccessMethodTerminal is special since it's not a real app and only
// applies to the PTY endpoint on the API.
AccessMethodTerminal AccessMethod = "terminal"
)
type Request struct {
AccessMethod AccessMethod
AccessMethod AccessMethod `json:"access_method"`
// BasePath 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.
BasePath string
BasePath string `json:"base_path"`
UsernameOrID string
// 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.
UsernameOrID string `json:"username_or_id"`
// WorkspaceAndAgent xor WorkspaceNameOrID are required.
WorkspaceAndAgent string // "workspace" or "workspace.agent"
WorkspaceNameOrID string
WorkspaceAndAgent string `json:"-"` // "workspace" or "workspace.agent"
WorkspaceNameOrID string `json:"workspace_name_or_id"`
// AgentNameOrID is not required if the workspace has only one agent.
AgentNameOrID string
AppSlugOrPort string
AgentNameOrID string `json:"agent_name_or_id"`
AppSlugOrPort string `json:"app_slug_or_port"`
}
func (r Request) Validate() error {
if r.AccessMethod != AccessMethodPath && r.AccessMethod != AccessMethodSubdomain {
switch r.AccessMethod {
case AccessMethodPath, AccessMethodSubdomain, AccessMethodTerminal:
default:
return xerrors.Errorf("invalid access method: %q", r.AccessMethod)
}
if r.BasePath == "" {
return xerrors.New("base path is required")
}
if r.AccessMethod == AccessMethodTerminal {
if r.UsernameOrID != "" || r.WorkspaceAndAgent != "" || r.WorkspaceNameOrID != "" || r.AppSlugOrPort != "" {
return xerrors.New("dev error: cannot specify any fields other than r.AccessMethod, r.BasePath and r.AgentNameOrID for terminal access method")
}
if r.AgentNameOrID == "" {
return xerrors.New("agent name or ID is required")
}
if _, err := uuid.Parse(r.AgentNameOrID); err != nil {
return xerrors.Errorf("invalid agent name or ID %q, must be a UUID: %w", r.AgentNameOrID, err)
}
return nil
}
if r.UsernameOrID == "" {
return xerrors.New("username or ID is required")
}
@ -71,3 +102,240 @@ func (r Request) Validate() error {
return nil
}
type databaseRequest struct {
Request
// User is the user that owns the app.
User database.User
// Workspace is the workspace that the app is in.
Workspace database.Workspace
// Agent is the agent that the app is running on.
Agent database.WorkspaceAgent
// AppURL is the resolved URL to the workspace app. This is only set for non
// terminal requests.
AppURL string
// AppHealth is the health of the app. For terminal requests, this is always
// database.WorkspaceAppHealthHealthy.
AppHealth database.WorkspaceAppHealth
// AppSharingLevel is the sharing level of the app. This is forced to be set
// to AppSharingLevelOwner if the access method is terminal.
AppSharingLevel database.AppSharingLevel
}
// getDatabase does queries to get the owner user, workspace and agent
// associated with the app in the request. This will correctly perform the
// queries in the correct order based on the access method and what fields are
// available.
//
// If any of the queries don't return any rows, the error will wrap
// sql.ErrNoRows. All other errors should be considered internal server errors.
func (r Request) getDatabase(ctx context.Context, db database.Store) (*databaseRequest, error) {
// If the AccessMethod is AccessMethodTerminal, then we need to get the
// agent first since that's the only info we have.
if r.AccessMethod == AccessMethodTerminal {
return r.getDatabaseTerminal(ctx, db)
}
// For non-terminal requests, get the objects in order since we have all
// fields available.
// Get user.
var (
user database.User
userErr error
)
if userID, uuidErr := uuid.Parse(r.UsernameOrID); uuidErr == nil {
user, userErr = db.GetUserByID(ctx, userID)
} else {
user, userErr = db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{
Username: r.UsernameOrID,
})
}
if userErr != nil {
return nil, xerrors.Errorf("get user %q: %w", r.UsernameOrID, userErr)
}
// Get workspace.
var (
workspace database.Workspace
workspaceErr error
)
if workspaceID, uuidErr := uuid.Parse(r.WorkspaceNameOrID); uuidErr == nil {
workspace, workspaceErr = db.GetWorkspaceByID(ctx, workspaceID)
} else {
workspace, workspaceErr = db.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: user.ID,
Name: r.WorkspaceNameOrID,
Deleted: false,
})
}
if workspaceErr != nil {
return nil, xerrors.Errorf("get workspace %q: %w", r.WorkspaceNameOrID, workspaceErr)
}
// Get workspace agents.
agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return nil, xerrors.Errorf("get workspace agents: %w", err)
}
if len(agents) == 0 {
// TODO(@deansheather): return a 404 if there are no agents in the
// workspace, requires a different error type.
return nil, xerrors.New("no agents in workspace")
}
// Get workspace apps.
agentIDs := make([]uuid.UUID, len(agents))
for i, agent := range agents {
agentIDs[i] = agent.ID
}
apps, err := db.GetWorkspaceAppsByAgentIDs(ctx, agentIDs)
if err != nil {
return nil, xerrors.Errorf("get workspace apps: %w", err)
}
// Get the app first, because r.AgentNameOrID is optional depending on
// whether the app is a slug or a port and whether there are multiple agents
// in the workspace or not.
var (
agentNameOrID = r.AgentNameOrID
appURL string
appSharingLevel database.AppSharingLevel
appHealth = database.WorkspaceAppHealthDisabled
portUint, portUintErr = strconv.ParseUint(r.AppSlugOrPort, 10, 16)
)
if portUintErr == nil {
if r.AccessMethod != AccessMethodSubdomain {
// TODO(@deansheather): this should return a 400 instead of a 500.
return nil, xerrors.New("port-based URLs are only supported for subdomain-based applications")
}
// If the user specified a port, then they must specify the agent if
// there are multiple agents in the workspace. App names are unique per
// workspace.
if agentNameOrID == "" {
if len(agents) != 1 {
return nil, xerrors.New("port specified with no agent, but multiple agents exist in the workspace")
}
agentNameOrID = agents[0].ID.String()
}
// If the app slug is a port number, then route to the port as an
// "anonymous app". We only support HTTP for port-based URLs.
//
// This is only supported for subdomain-based applications.
appURL = fmt.Sprintf("http://127.0.0.1:%d", portUint)
appSharingLevel = database.AppSharingLevelOwner
} else {
for _, app := range apps {
if app.Slug == r.AppSlugOrPort {
if !app.Url.Valid {
return nil, xerrors.Errorf("app URL is not valid")
}
agentNameOrID = app.AgentID.String()
if app.SharingLevel != "" {
appSharingLevel = app.SharingLevel
} else {
appSharingLevel = database.AppSharingLevelOwner
}
appURL = app.Url.String
appHealth = app.Health
break
}
}
}
if appURL == "" {
return nil, xerrors.Errorf("no app found with slug %q: %w", r.AppSlugOrPort, sql.ErrNoRows)
}
// Finally, get agent.
var agent database.WorkspaceAgent
if agentID, uuidErr := uuid.Parse(agentNameOrID); uuidErr == nil {
for _, a := range agents {
if a.ID == agentID {
agent = a
break
}
}
} else {
if agentNameOrID == "" && len(agents) == 1 {
agent = agents[0]
} else {
for _, a := range agents {
if a.Name == agentNameOrID {
agent = a
break
}
}
}
if agent.ID == uuid.Nil {
return nil, xerrors.Errorf("no agent found with name %q: %w", r.AgentNameOrID, sql.ErrNoRows)
}
}
return &databaseRequest{
Request: r,
User: user,
Workspace: workspace,
Agent: agent,
AppURL: appURL,
AppHealth: appHealth,
AppSharingLevel: appSharingLevel,
}, nil
}
// getDatabaseTerminal is called by getDatabase for AccessMethodTerminal
// requests.
func (r Request) getDatabaseTerminal(ctx context.Context, db database.Store) (*databaseRequest, error) {
if r.AccessMethod != AccessMethodTerminal {
return nil, xerrors.Errorf("invalid access method %q for terminal request", r.AccessMethod)
}
agentID, uuidErr := uuid.Parse(r.AgentNameOrID)
if uuidErr != nil {
return nil, xerrors.Errorf("invalid agent name or ID %q, must be a UUID for terminal requests: %w", r.AgentNameOrID, uuidErr)
}
var err error
agent, err := db.GetWorkspaceAgentByID(ctx, agentID)
if err != nil {
return nil, xerrors.Errorf("get workspace agent %q: %w", agentID, err)
}
// Get the corresponding resource.
res, err := db.GetWorkspaceResourceByID(ctx, agent.ResourceID)
if err != nil {
return nil, xerrors.Errorf("get workspace agent resource %q: %w", agent.ResourceID, err)
}
// Get the corresponding workspace build.
build, err := db.GetWorkspaceBuildByJobID(ctx, res.JobID)
if err != nil {
return nil, xerrors.Errorf("get workspace build by job ID %q: %w", res.JobID, err)
}
// Get the corresponding workspace.
workspace, err := db.GetWorkspaceByID(ctx, build.WorkspaceID)
if err != nil {
return nil, xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err)
}
// Get the workspace's owner.
user, err := db.GetUserByID(ctx, workspace.OwnerID)
if err != nil {
return nil, xerrors.Errorf("get user %q: %w", workspace.OwnerID, err)
}
return &databaseRequest{
Request: r,
User: user,
Workspace: workspace,
Agent: agent,
AppURL: "",
AppHealth: database.WorkspaceAppHealthHealthy,
AppSharingLevel: database.AppSharingLevelOwner,
}, nil
}

View File

@ -3,6 +3,7 @@ package workspaceapps_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/workspaceapps"
@ -39,6 +40,14 @@ func Test_RequestValidate(t *testing.T) {
},
{
name: "OK3",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
AgentNameOrID: uuid.New().String(),
},
},
{
name: "OK4",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/",
@ -188,6 +197,64 @@ func Test_RequestValidate(t *testing.T) {
},
errContains: "app slug or port is required",
},
{
name: "Terminal/OtherFields/UsernameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
UsernameOrID: "foo",
AgentNameOrID: uuid.New().String(),
},
errContains: "cannot specify any fields other than",
},
{
name: "Terminal/OtherFields/WorkspaceAndAgent",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
WorkspaceAndAgent: "bar.baz",
AgentNameOrID: uuid.New().String(),
},
errContains: "cannot specify any fields other than",
},
{
name: "Terminal/OtherFields/WorkspaceNameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
WorkspaceNameOrID: "bar",
AgentNameOrID: uuid.New().String(),
},
errContains: "cannot specify any fields other than",
},
{
name: "Terminal/OtherFields/AppSlugOrPort",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
AgentNameOrID: uuid.New().String(),
AppSlugOrPort: "baz",
},
errContains: "cannot specify any fields other than",
},
{
name: "Terminal/AgentNameOrID/Empty",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
AgentNameOrID: "",
},
errContains: "agent name or ID is required",
},
{
name: "Terminal/AgentNameOrID/NotUUID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/",
AgentNameOrID: "baz",
},
errContains: `invalid agent name or ID "baz", must be a UUID`,
},
}
for _, c := range cases {
@ -204,3 +271,6 @@ func Test_RequestValidate(t *testing.T) {
})
}
}
// getDatabase is tested heavily in auth_test.go, so we don't have specific
// tests for it here.

View File

@ -18,11 +18,7 @@ const ticketSigningAlgorithm = jose.HS512
// The JSON field names are short to reduce the size of the ticket.
type Ticket struct {
// Request details.
AccessMethod AccessMethod `json:"access_method"`
UsernameOrID string `json:"username_or_id"`
WorkspaceNameOrID string `json:"workspace_name_or_id"`
AgentNameOrID string `json:"agent_name_or_id"`
AppSlugOrPort string `json:"app_slug_or_port"`
Request `json:"request"`
// Trusted resolved details.
Expiry int64 `json:"expiry"` // set by GenerateTicket if unset
@ -34,6 +30,7 @@ type Ticket struct {
func (t Ticket) MatchesRequest(req Request) bool {
return t.AccessMethod == req.AccessMethod &&
t.BasePath == req.BasePath &&
t.UsernameOrID == req.UsernameOrID &&
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
t.AgentNameOrID == req.AgentNameOrID &&

View File

@ -28,17 +28,21 @@ func Test_TicketMatchesRequest(t *testing.T) {
name: "OK",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
},
want: true,
},
@ -48,7 +52,22 @@ func Test_TicketMatchesRequest(t *testing.T) {
AccessMethod: workspaceapps.AccessMethodPath,
},
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodSubdomain,
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
},
},
want: false,
},
{
name: "DifferentBasePath",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
},
ticket: workspaceapps.Ticket{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
},
},
want: false,
},
@ -56,11 +75,15 @@ func Test_TicketMatchesRequest(t *testing.T) {
name: "DifferentUsernameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
},
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "bar",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "bar",
},
},
want: false,
},
@ -68,13 +91,17 @@ func Test_TicketMatchesRequest(t *testing.T) {
name: "DifferentWorkspaceNameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
},
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "baz",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "baz",
},
},
want: false,
},
@ -82,15 +109,19 @@ func Test_TicketMatchesRequest(t *testing.T) {
name: "DifferentAgentNameOrID",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
},
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "qux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "qux",
},
},
want: false,
},
@ -98,17 +129,21 @@ func Test_TicketMatchesRequest(t *testing.T) {
name: "DifferentAppSlugOrPort",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "quux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "quux",
},
},
want: false,
},
@ -128,17 +163,20 @@ func Test_TicketMatchesRequest(t *testing.T) {
func Test_GenerateTicket(t *testing.T) {
t.Parallel()
provider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, coderdtest.AppSigningKey)
provider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, time.Minute, coderdtest.AppSigningKey)
t.Run("SetExpiry", func(t *testing.T) {
t.Parallel()
ticketStr, err := provider.GenerateTicket(workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: 0,
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
@ -163,11 +201,14 @@ func Test_GenerateTicket(t *testing.T) {
{
name: "OK1",
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: future,
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
@ -179,11 +220,14 @@ func Test_GenerateTicket(t *testing.T) {
{
name: "OK2",
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodSubdomain,
UsernameOrID: "oof",
WorkspaceNameOrID: "rab",
AgentNameOrID: "zab",
AppSlugOrPort: "xuq",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: "oof",
WorkspaceNameOrID: "rab",
AgentNameOrID: "zab",
AppSlugOrPort: "xuq",
},
Expiry: future,
UserID: uuid.MustParse("6fa684a3-11aa-49fd-8512-ab527bd9b900"),
@ -195,11 +239,14 @@ func Test_GenerateTicket(t *testing.T) {
{
name: "Expired",
ticket: workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodSubdomain,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(-time.Hour).Unix(),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
@ -239,7 +286,7 @@ func Test_GenerateTicket(t *testing.T) {
func Test_ParseTicket(t *testing.T) {
t.Parallel()
provider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, coderdtest.AppSigningKey)
provider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, time.Minute, coderdtest.AppSigningKey)
t.Run("InvalidJWS", func(t *testing.T) {
t.Parallel()
@ -259,14 +306,17 @@ func Test_ParseTicket(t *testing.T) {
require.NotEqual(t, coderdtest.AppSigningKey, otherKey)
require.Len(t, otherKey, 64)
otherProvider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, otherKey)
otherProvider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, time.Minute, otherKey)
ticketStr, err := otherProvider.GenerateTicket(workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(time.Hour).Unix(),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),