mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: use JWT ticket to avoid DB queries on apps (#6148)
Issue a JWT ticket on the first request with a short expiry that contains details about which workspace/agent/app combo the ticket is valid for.
This commit is contained in:
494
coderd/workspaceapps/auth.go
Normal file
494
coderd/workspaceapps/auth.go
Normal file
@ -0,0 +1,494 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"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/codersdk"
|
||||
"github.com/coder/coder/site"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO(@deansheather): configurable expiry
|
||||
TicketExpiry = time.Minute
|
||||
|
||||
// RedirectURIQueryParam is the query param for the app URL to be passed
|
||||
// back to the API auth endpoint on the main access URL.
|
||||
RedirectURIQueryParam = "redirect_uri"
|
||||
)
|
||||
|
||||
// ResolveRequest takes an app request, checks if it's valid and authenticated,
|
||||
// and returns a ticket with details about the app.
|
||||
//
|
||||
// The ticket is written as a signed JWT into a cookie and will be automatically
|
||||
// used in the next request to the same app to avoid database calls.
|
||||
//
|
||||
// Upstream code should avoid any database calls ever.
|
||||
func (p *Provider) ResolveRequest(rw http.ResponseWriter, r *http.Request, appReq Request) (*Ticket, bool) {
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, err, "invalid app request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if appReq.WorkspaceAndAgent != "" {
|
||||
// workspace.agent
|
||||
workspaceAndAgent := strings.SplitN(appReq.WorkspaceAndAgent, ".", 2)
|
||||
appReq.WorkspaceAndAgent = ""
|
||||
appReq.WorkspaceNameOrID = workspaceAndAgent[0]
|
||||
if len(workspaceAndAgent) > 1 {
|
||||
appReq.AgentNameOrID = workspaceAndAgent[1]
|
||||
}
|
||||
|
||||
// Sanity check.
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, err, "invalid app request")
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// Get the existing ticket from the request.
|
||||
ticketCookie, err := r.Cookie(codersdk.DevURLSessionTicketCookie)
|
||||
if err == nil {
|
||||
ticket, err := p.ParseTicket(ticketCookie.Value)
|
||||
if err == nil {
|
||||
if 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There's no ticket or it's invalid, so we need to check auth using the
|
||||
// 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,
|
||||
}
|
||||
|
||||
// We use the regular API apiKey extraction middleware fn here to avoid any
|
||||
// differences in behavior between the two.
|
||||
apiKey, authz, ok := httpmw.ExtractAPIKey(rw, r, httpmw.ExtractAPIKeyConfig{
|
||||
DB: p.Database,
|
||||
OAuth2Configs: p.OAuth2Configs,
|
||||
RedirectToLogin: false,
|
||||
DisableSessionExpiryRefresh: p.DeploymentConfig.DisableSessionExpiryRefresh.Value,
|
||||
// Optional is true to allow for public apps. If an authorization check
|
||||
// fails and the user is not authenticated, they will be redirected to
|
||||
// the login page using code below (not the redirect from the
|
||||
// middleware itself).
|
||||
Optional: true,
|
||||
})
|
||||
if !ok {
|
||||
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(r.Context(), userID)
|
||||
} else {
|
||||
user, userErr = p.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
|
||||
Username: appReq.UsernameOrID,
|
||||
})
|
||||
}
|
||||
if xerrors.Is(userErr, sql.ErrNoRows) {
|
||||
p.writeWorkspaceApp404(rw, r, &appReq, fmt.Sprintf("user %q not found", appReq.UsernameOrID))
|
||||
return nil, false
|
||||
} else if userErr != nil {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, userErr, "get user")
|
||||
return nil, false
|
||||
}
|
||||
ticket.UserID = user.ID
|
||||
|
||||
// Get workspace.
|
||||
var (
|
||||
workspace database.Workspace
|
||||
workspaceErr error
|
||||
)
|
||||
if workspaceID, uuidErr := uuid.Parse(appReq.WorkspaceNameOrID); uuidErr == nil {
|
||||
workspace, workspaceErr = p.Database.GetWorkspaceByID(r.Context(), workspaceID)
|
||||
} else {
|
||||
workspace, workspaceErr = p.Database.GetWorkspaceByOwnerIDAndName(r.Context(), 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(r.Context(), agentID)
|
||||
} else {
|
||||
build, err := p.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
|
||||
if err != nil {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, err, "get latest workspace build")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
resources, err := p.Database.GetWorkspaceResourcesByJobID(r.Context(), 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)
|
||||
}
|
||||
|
||||
agents, err := p.Database.GetWorkspaceAgentsByResourceIDs(r.Context(), 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 {
|
||||
agentResource, err := p.Database.GetWorkspaceResourceByID(r.Context(), agent.ResourceID)
|
||||
if err != nil {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, err, "get agent resource")
|
||||
return nil, false
|
||||
}
|
||||
build, err := p.Database.GetWorkspaceBuildByJobID(r.Context(), 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
|
||||
}
|
||||
|
||||
// Verify the user has access to the app.
|
||||
authed, ok := p.fetchWorkspaceApplicationAuth(rw, r, authz, appReq.AccessMethod, workspace, appSharingLevel)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !authed {
|
||||
if apiKey != nil {
|
||||
// The request has a valid API key but insufficient permissions.
|
||||
p.writeWorkspaceApp404(rw, r, &appReq, "insufficient permissions")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Redirect to login as they don't have permission to access the app
|
||||
// and they aren't signed in.
|
||||
if appReq.AccessMethod == AccessMethodSubdomain {
|
||||
redirectURI := *r.URL
|
||||
redirectURI.Scheme = p.AccessURL.Scheme
|
||||
redirectURI.Host = httpapi.RequestHost(r)
|
||||
|
||||
u := *p.AccessURL
|
||||
u.Path = "/api/v2/applications/auth-redirect"
|
||||
q := u.Query()
|
||||
q.Add(RedirectURIQueryParam, redirectURI.String())
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
|
||||
} else {
|
||||
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// As a sanity check, ensure the ticket we just made is valid for this
|
||||
// request.
|
||||
if !ticket.MatchesRequest(appReq) {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, nil, "fresh ticket does not match request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Sign the ticket.
|
||||
ticketExpiry := time.Now().Add(TicketExpiry)
|
||||
ticket.Expiry = ticketExpiry.Unix()
|
||||
ticketStr, err := p.GenerateTicket(ticket)
|
||||
if err != nil {
|
||||
p.writeWorkspaceApp500(rw, r, &appReq, err, "generate ticket")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Write the ticket cookie. We always want this to apply to the current
|
||||
// hostname (even for subdomain apps, without any wildcard shenanigans,
|
||||
// because the ticket is only valid for a single app).
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: codersdk.DevURLSessionTicketCookie,
|
||||
Value: ticketStr,
|
||||
Path: appReq.BasePath,
|
||||
Expires: ticketExpiry,
|
||||
})
|
||||
|
||||
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) {
|
||||
app, err := p.Database.GetWorkspaceAppByAgentIDAndSlug(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) {
|
||||
if accessMethod == "" {
|
||||
accessMethod = AccessMethodPath
|
||||
}
|
||||
isPathApp := accessMethod == AccessMethodPath
|
||||
|
||||
// If path-based app sharing is disabled (which is the default), we can
|
||||
// force the sharing level to be "owner" so that the user can only access
|
||||
// their own apps.
|
||||
//
|
||||
// Site owners are blocked from accessing path-based apps unless the
|
||||
// Dangerous.AllowPathAppSiteOwnerAccess flag is enabled in the check below.
|
||||
if isPathApp && !p.DeploymentConfig.Dangerous.AllowPathAppSharing.Value {
|
||||
sharingLevel = database.AppSharingLevelOwner
|
||||
}
|
||||
|
||||
// Short circuit if not authenticated.
|
||||
if roles == nil {
|
||||
// The user is not authenticated, so they can only access the app if it
|
||||
// is public.
|
||||
return sharingLevel == database.AppSharingLevelPublic, nil
|
||||
}
|
||||
|
||||
// Block anyone from accessing workspaces they don't own in path-based apps
|
||||
// unless the admin disables this security feature. This blocks site-owners
|
||||
// from accessing any apps from any user's workspaces.
|
||||
//
|
||||
// When the Dangerous.AllowPathAppSharing flag is not enabled, the sharing
|
||||
// level will be forced to "owner", so this check will always be true for
|
||||
// workspaces owned by different users.
|
||||
if isPathApp &&
|
||||
sharingLevel == database.AppSharingLevelOwner &&
|
||||
workspace.OwnerID.String() != roles.Actor.ID &&
|
||||
!p.DeploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 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())
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
switch sharingLevel {
|
||||
case database.AppSharingLevelOwner:
|
||||
// We essentially already did this above with the regular RBAC check.
|
||||
// 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)
|
||||
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.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// No checks were successful.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// fetchWorkspaceApplicationAuth 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)
|
||||
if err != nil {
|
||||
p.Logger.Error(r.Context(), "authorize workspace app", slog.Error(err))
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "Could not verify authorization. Please try again or contact an administrator.",
|
||||
RetryEnabled: true,
|
||||
DashboardURL: p.AccessURL.String(),
|
||||
})
|
||||
return false, false
|
||||
}
|
||||
|
||||
return ok, true
|
||||
}
|
||||
|
||||
// writeWorkspaceApp404 writes a HTML 404 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) writeWorkspaceApp404(rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
|
||||
if appReq != nil {
|
||||
slog.Helper()
|
||||
p.Logger.Debug(r.Context(),
|
||||
"workspace app 404: "+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.StatusNotFound,
|
||||
Title: "Application Not Found",
|
||||
Description: "The application or workspace you are trying to access does not exist or you do not have permission to access it.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: p.AccessURL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// writeWorkspaceApp500 writes a HTML 500 error page for a workspace app. If
|
||||
// appReq is not nil, it's fields will be added to the logged error message.
|
||||
func (p *Provider) writeWorkspaceApp500(rw http.ResponseWriter, r *http.Request, appReq *Request, err error, msg string) {
|
||||
slog.Helper()
|
||||
ctx := r.Context()
|
||||
if appReq != nil {
|
||||
slog.With(ctx,
|
||||
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_name_or_port", appReq.AppSlugOrPort),
|
||||
)
|
||||
}
|
||||
p.Logger.Warn(ctx,
|
||||
"workspace app auth server error: "+msg,
|
||||
slog.Error(err),
|
||||
)
|
||||
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
Title: "Internal Server Error",
|
||||
Description: "An internal server error occurred.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: p.AccessURL.String(),
|
||||
})
|
||||
}
|
582
coderd/workspaceapps/auth_test.go
Normal file
582
coderd/workspaceapps/auth_test.go
Normal file
@ -0,0 +1,582 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/codersdk/agentsdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func Test_ResolveRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
agentName = "agent"
|
||||
appNameOwner = "app-owner"
|
||||
appNameAuthed = "app-authed"
|
||||
appNamePublic = "app-public"
|
||||
appNameInvalidURL = "app-invalid-url"
|
||||
|
||||
// This is not a valid URL we listen on in the test, but it needs to be
|
||||
// set to a value.
|
||||
appURL = "http://localhost:8080"
|
||||
)
|
||||
allApps := []string{appNameOwner, appNameAuthed, appNamePublic}
|
||||
|
||||
deploymentConfig := coderdtest.DeploymentConfig(t)
|
||||
deploymentConfig.DisablePathApps.Value = false
|
||||
deploymentConfig.Dangerous.AllowPathAppSharing.Value = true
|
||||
deploymentConfig.Dangerous.AllowPathAppSiteOwnerAccess.Value = true
|
||||
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
DeploymentConfig: deploymentConfig,
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
MetricsCacheRefreshInterval: time.Millisecond * 100,
|
||||
RealIPConfig: &httpmw.RealIPConfig{
|
||||
TrustedOrigins: []*net.IPNet{{
|
||||
IP: net.ParseIP("127.0.0.1"),
|
||||
Mask: net.CIDRMask(8, 32),
|
||||
}},
|
||||
TrustedHeaders: []string{
|
||||
"CF-Connecting-IP",
|
||||
},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = closer.Close()
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancel()
|
||||
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondUserClient, _ := coderdtest.CreateAnotherUser(t, client, firstUser.OrganizationID)
|
||||
|
||||
agentAuthToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, firstUser.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentAuthToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
agentID := uuid.Nil
|
||||
for _, resource := range resources {
|
||||
for _, agnt := range resource.Agents {
|
||||
if agnt.Name == agentName {
|
||||
agentID = agnt.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NotEqual(t, uuid.Nil, agentID)
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
workspaceNameOrID string
|
||||
agentNameOrID string
|
||||
}{
|
||||
{
|
||||
name: "Names",
|
||||
workspaceNameOrID: workspace.Name,
|
||||
agentNameOrID: agentName,
|
||||
},
|
||||
{
|
||||
name: "IDs",
|
||||
workspaceNameOrID: workspace.ID.String(),
|
||||
agentNameOrID: agentID.String(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Try resolving a request for each app as the owner, without a ticket,
|
||||
// then use the ticket to resolve each app.
|
||||
for _, app := range allApps {
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: c.workspaceNameOrID,
|
||||
AgentNameOrID: c.agentNameOrID,
|
||||
AppSlugOrPort: app,
|
||||
}
|
||||
|
||||
t.Log("app", app)
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
// Try resolving the request without a ticket.
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
w := rw.Result()
|
||||
if !assert.True(t, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
require.NoError(t, err, "error dumping failed response")
|
||||
t.Log(string(dump))
|
||||
return
|
||||
}
|
||||
_ = 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,
|
||||
}, ticket)
|
||||
require.NotZero(t, ticket.Expiry)
|
||||
require.InDelta(t, time.Now().Add(workspaceapps.TicketExpiry).Unix(), ticket.Expiry, time.Minute.Seconds())
|
||||
|
||||
// Check that the ticket was set in the response and is valid.
|
||||
require.Len(t, w.Cookies(), 1)
|
||||
cookie := w.Cookies()[0]
|
||||
require.Equal(t, codersdk.DevURLSessionTicketCookie, cookie.Name)
|
||||
require.Equal(t, req.BasePath, cookie.Path)
|
||||
|
||||
parsedTicket, err := api.WorkspaceAppsProvider.ParseTicket(cookie.Value)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ticket, &parsedTicket)
|
||||
|
||||
// Try resolving the request with the ticket only.
|
||||
rw = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/app", nil)
|
||||
r.AddCookie(cookie)
|
||||
|
||||
secondTicket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, ticket, secondTicket)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AuthenticatedOtherUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, app := range allApps {
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
AppSlugOrPort: app,
|
||||
}
|
||||
|
||||
t.Log("app", app)
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
||||
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
w := rw.Result()
|
||||
_ = w.Body.Close()
|
||||
if app == appNameOwner {
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ticket)
|
||||
require.NotZero(t, w.StatusCode)
|
||||
require.Equal(t, http.StatusNotFound, w.StatusCode)
|
||||
return
|
||||
}
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, ticket)
|
||||
require.Zero(t, w.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unauthenticated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, app := range allApps {
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
AppSlugOrPort: app,
|
||||
}
|
||||
|
||||
t.Log("app", app)
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
w := rw.Result()
|
||||
if app != appNamePublic {
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ticket)
|
||||
require.NotZero(t, rw.Code)
|
||||
require.NotEqual(t, http.StatusOK, rw.Code)
|
||||
} else {
|
||||
if !assert.True(t, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
require.NoError(t, err, "error dumping failed response")
|
||||
t.Log(string(dump))
|
||||
return
|
||||
}
|
||||
require.NotNil(t, ticket)
|
||||
if rw.Code != 0 && rw.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code)
|
||||
}
|
||||
}
|
||||
_ = w.Body.Close()
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: "invalid",
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ticket)
|
||||
})
|
||||
|
||||
t.Run("SplitWorkspaceAndAgent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
workspaceAndAgent string
|
||||
workspace string
|
||||
agent string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "WorkspaecOnly",
|
||||
workspaceAndAgent: workspace.Name,
|
||||
workspace: workspace.Name,
|
||||
agent: "",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "WorkspaceAndAgent",
|
||||
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.Name, agentName),
|
||||
workspace: workspace.Name,
|
||||
agent: agentName,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "WorkspaceID",
|
||||
workspaceAndAgent: workspace.ID.String(),
|
||||
workspace: workspace.ID.String(),
|
||||
agent: "",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "WorkspaceIDAndAgentID",
|
||||
workspaceAndAgent: fmt.Sprintf("%s.%s", workspace.ID, agentID),
|
||||
workspace: workspace.ID.String(),
|
||||
agent: agentID.String(),
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid1",
|
||||
workspaceAndAgent: "invalid",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid2",
|
||||
workspaceAndAgent: ".",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "Slash",
|
||||
workspaceAndAgent: fmt.Sprintf("%s/%s", workspace.Name, agentName),
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceAndAgent: c.workspaceAndAgent,
|
||||
AppSlugOrPort: appNamePublic,
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
w := rw.Result()
|
||||
if !assert.Equal(t, c.ok, ok) {
|
||||
dump, err := httputil.DumpResponse(w, true)
|
||||
require.NoError(t, err, "error dumping failed response")
|
||||
t.Log(string(dump))
|
||||
return
|
||||
}
|
||||
if c.ok {
|
||||
require.NotNil(t, ticket)
|
||||
require.Equal(t, ticket.WorkspaceNameOrID, c.workspace)
|
||||
require.Equal(t, ticket.AgentNameOrID, c.agent)
|
||||
require.Equal(t, ticket.WorkspaceID, workspace.ID)
|
||||
require.Equal(t, ticket.AgentID, agentID)
|
||||
} else {
|
||||
require.Nil(t, ticket)
|
||||
}
|
||||
_ = w.Body.Close()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TicketDoesNotMatchRequest", func(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,
|
||||
}
|
||||
badTicketStr, err := api.WorkspaceAppsProvider.GenerateTicket(badTicket)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
// App name differs
|
||||
AppSlugOrPort: appNameOwner,
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
|
||||
r.AddCookie(&http.Cookie{
|
||||
Name: codersdk.DevURLSessionTicketCookie,
|
||||
Value: badTicketStr,
|
||||
})
|
||||
|
||||
// Even though the ticket is invalid, we should still perform request
|
||||
// resolution.
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, ticket)
|
||||
require.Equal(t, appNameOwner, ticket.AppSlugOrPort)
|
||||
|
||||
// Cookie should be set in response, and it should be a different
|
||||
// ticket.
|
||||
w := rw.Result()
|
||||
_ = w.Body.Close()
|
||||
cookies := w.Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, cookies[0].Name, codersdk.DevURLSessionTicketCookie)
|
||||
require.NotEqual(t, cookies[0].Value, badTicketStr)
|
||||
parsedTicket, err := api.WorkspaceAppsProvider.ParseTicket(cookies[0].Value)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appNameOwner, parsedTicket.AppSlugOrPort)
|
||||
})
|
||||
|
||||
t.Run("PortPathBlocked", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
AppSlugOrPort: "8080",
|
||||
}
|
||||
|
||||
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)
|
||||
require.Nil(t, ticket)
|
||||
})
|
||||
|
||||
t.Run("PortSubdomain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
AppSlugOrPort: "9090",
|
||||
}
|
||||
|
||||
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.AppSlugOrPort, ticket.AppSlugOrPort)
|
||||
require.Equal(t, "http://127.0.0.1:9090", ticket.AppURL)
|
||||
})
|
||||
|
||||
t.Run("InsufficientPermissions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
AppSlugOrPort: appNameOwner,
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/app", nil)
|
||||
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
|
||||
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ticket)
|
||||
})
|
||||
|
||||
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: me.Username,
|
||||
WorkspaceNameOrID: workspace.Name,
|
||||
AgentNameOrID: agentName,
|
||||
AppSlugOrPort: appNameOwner,
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/some-path", nil)
|
||||
r.Host = "app.com"
|
||||
|
||||
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, ticket)
|
||||
|
||||
w := rw.Result()
|
||||
defer w.Body.Close()
|
||||
require.Equal(t, http.StatusTemporaryRedirect, w.StatusCode)
|
||||
|
||||
loc, err := w.Location()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, api.AccessURL.Scheme, loc.Scheme)
|
||||
require.Equal(t, api.AccessURL.Host, loc.Host)
|
||||
require.Equal(t, "/api/v2/applications/auth-redirect", loc.Path)
|
||||
|
||||
redirectURIStr := loc.Query().Get(workspaceapps.RedirectURIQueryParam)
|
||||
redirectURI, err := url.Parse(redirectURIStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "http", redirectURI.Scheme)
|
||||
require.Equal(t, "app.com", redirectURI.Host)
|
||||
require.Equal(t, "/some-path", redirectURI.Path)
|
||||
})
|
||||
}
|
41
coderd/workspaceapps/provider.go
Normal file
41
coderd/workspaceapps/provider.go
Normal file
@ -0,0 +1,41 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// Provider provides authentication and authorization for workspace apps.
|
||||
// TODO(@deansheather): also provide workspace apps as a whole to remove all app
|
||||
// code from coderd.
|
||||
type Provider struct {
|
||||
Logger slog.Logger
|
||||
|
||||
AccessURL *url.URL
|
||||
Authorizer rbac.Authorizer
|
||||
Database database.Store
|
||||
DeploymentConfig *codersdk.DeploymentConfig
|
||||
OAuth2Configs *httpmw.OAuth2Configs
|
||||
TicketSigningKey []byte
|
||||
}
|
||||
|
||||
func New(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentConfig, oauth2Cfgs *httpmw.OAuth2Configs, ticketSigningKey []byte) *Provider {
|
||||
if len(ticketSigningKey) != 64 {
|
||||
panic("ticket signing key must be 64 bytes")
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
Logger: log,
|
||||
AccessURL: accessURL,
|
||||
Authorizer: authz,
|
||||
Database: db,
|
||||
DeploymentConfig: cfg,
|
||||
OAuth2Configs: oauth2Cfgs,
|
||||
TicketSigningKey: ticketSigningKey,
|
||||
}
|
||||
}
|
73
coderd/workspaceapps/request.go
Normal file
73
coderd/workspaceapps/request.go
Normal file
@ -0,0 +1,73 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type AccessMethod string
|
||||
|
||||
const (
|
||||
AccessMethodPath AccessMethod = "path"
|
||||
AccessMethodSubdomain AccessMethod = "subdomain"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
AccessMethod AccessMethod
|
||||
// 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
|
||||
|
||||
UsernameOrID string
|
||||
// WorkspaceAndAgent xor WorkspaceNameOrID are required.
|
||||
WorkspaceAndAgent string // "workspace" or "workspace.agent"
|
||||
WorkspaceNameOrID string
|
||||
// AgentNameOrID is not required if the workspace has only one agent.
|
||||
AgentNameOrID string
|
||||
AppSlugOrPort string
|
||||
}
|
||||
|
||||
func (r Request) Validate() error {
|
||||
if r.AccessMethod != AccessMethodPath && r.AccessMethod != AccessMethodSubdomain {
|
||||
return xerrors.Errorf("invalid access method: %q", r.AccessMethod)
|
||||
}
|
||||
if r.BasePath == "" {
|
||||
return xerrors.New("base path is required")
|
||||
}
|
||||
if r.UsernameOrID == "" {
|
||||
return xerrors.New("username or ID is required")
|
||||
}
|
||||
if r.UsernameOrID == codersdk.Me {
|
||||
// We block "me" for workspace app auth to avoid any security issues
|
||||
// caused by having an identical workspace name on yourself and a
|
||||
// different user and potentially reusing a ticket.
|
||||
//
|
||||
// This is also mitigated by storing the workspace/agent ID in the
|
||||
// ticket, but we block it here to be double safe.
|
||||
//
|
||||
// Subdomain apps have never been used with "me" from our code, and path
|
||||
// apps now have a redirect to remove the "me" from the URL.
|
||||
return xerrors.New(`username cannot be "me" in app requests`)
|
||||
}
|
||||
if r.WorkspaceAndAgent != "" {
|
||||
split := strings.Split(r.WorkspaceAndAgent, ".")
|
||||
if split[0] == "" || (len(split) == 2 && split[1] == "") || len(split) > 2 {
|
||||
return xerrors.Errorf("invalid workspace and agent: %q", r.WorkspaceAndAgent)
|
||||
}
|
||||
if r.WorkspaceNameOrID != "" || r.AgentNameOrID != "" {
|
||||
return xerrors.New("dev error: cannot specify both WorkspaceAndAgent and (WorkspaceNameOrID and AgentNameOrID)")
|
||||
}
|
||||
}
|
||||
if r.WorkspaceAndAgent == "" && r.WorkspaceNameOrID == "" {
|
||||
return xerrors.New("workspace name or ID is required")
|
||||
}
|
||||
if r.AppSlugOrPort == "" {
|
||||
return xerrors.New("app slug or port is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
206
coderd/workspaceapps/request_test.go
Normal file
206
coderd/workspaceapps/request_test.go
Normal file
@ -0,0 +1,206 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
)
|
||||
|
||||
func Test_RequestValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req workspaceapps.Request
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "OK1",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK2",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceAndAgent: "bar.baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK3",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AppSlugOrPort: "baz",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NoAccessMethod",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: "",
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "invalid access method",
|
||||
},
|
||||
{
|
||||
name: "UnknownAccessMethod",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: "dean was here",
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "invalid access method",
|
||||
},
|
||||
{
|
||||
name: "NoBasePath",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "base path is required",
|
||||
},
|
||||
{
|
||||
name: "NoUsernameOrID",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "username or ID is required",
|
||||
},
|
||||
{
|
||||
name: "NoMe",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "me",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: `username cannot be "me"`,
|
||||
},
|
||||
{
|
||||
name: "InvalidWorkspaceAndAgent/Empty1",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceAndAgent: ".bar",
|
||||
AppSlugOrPort: "baz",
|
||||
},
|
||||
errContains: "invalid workspace and agent",
|
||||
},
|
||||
{
|
||||
name: "InvalidWorkspaceAndAgent/Empty2",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceAndAgent: "bar.",
|
||||
AppSlugOrPort: "baz",
|
||||
},
|
||||
errContains: "invalid workspace and agent",
|
||||
},
|
||||
{
|
||||
name: "InvalidWorkspaceAndAgent/TwoDots",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceAndAgent: "bar.baz.qux",
|
||||
AppSlugOrPort: "baz",
|
||||
},
|
||||
errContains: "invalid workspace and agent",
|
||||
},
|
||||
{
|
||||
name: "AmbiguousWorkspaceAndAgent/1",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceAndAgent: "bar.baz",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "cannot specify both",
|
||||
},
|
||||
{
|
||||
name: "AmbiguousWorkspaceAndAgent/2",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceAndAgent: "bar.baz",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "cannot specify both",
|
||||
},
|
||||
{
|
||||
name: "NoWorkspaceNameOrID",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
errContains: "workspace name or ID is required",
|
||||
},
|
||||
{
|
||||
name: "NoAppSlugOrPort",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/",
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "",
|
||||
},
|
||||
errContains: "app slug or port is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := c.req.Validate()
|
||||
if c.errContains == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
102
coderd/workspaceapps/ticket.go
Normal file
102
coderd/workspaceapps/ticket.go
Normal file
@ -0,0 +1,102 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
const ticketSigningAlgorithm = jose.HS512
|
||||
|
||||
// Ticket is the struct data contained inside a workspace app ticket JWE. It
|
||||
// contains the details of the workspace app that the ticket is valid for to
|
||||
// avoid database queries.
|
||||
//
|
||||
// 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"`
|
||||
|
||||
// Trusted resolved details.
|
||||
Expiry int64 `json:"expiry"` // set by GenerateTicket if unset
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
AppURL string `json:"app_url"`
|
||||
}
|
||||
|
||||
func (t Ticket) MatchesRequest(req Request) bool {
|
||||
return t.AccessMethod == req.AccessMethod &&
|
||||
t.UsernameOrID == req.UsernameOrID &&
|
||||
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
|
||||
t.AgentNameOrID == req.AgentNameOrID &&
|
||||
t.AppSlugOrPort == req.AppSlugOrPort
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateTicket(payload Ticket) (string, error) {
|
||||
if payload.Expiry == 0 {
|
||||
payload.Expiry = time.Now().Add(TicketExpiry).Unix()
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
// We use symmetric signing with an RSA key to support satellites in the
|
||||
// future.
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: ticketSigningAlgorithm,
|
||||
Key: p.TicketSigningKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("create signer: %w", err)
|
||||
}
|
||||
|
||||
signedObject, err := signer.Sign(payloadBytes)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("sign payload: %w", err)
|
||||
}
|
||||
|
||||
serialized, err := signedObject.CompactSerialize()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("serialize JWS: %w", err)
|
||||
}
|
||||
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ParseTicket(ticketStr string) (Ticket, error) {
|
||||
object, err := jose.ParseSigned(ticketStr)
|
||||
if err != nil {
|
||||
return Ticket{}, xerrors.Errorf("parse JWS: %w", err)
|
||||
}
|
||||
if len(object.Signatures) != 1 {
|
||||
return Ticket{}, xerrors.New("expected 1 signature")
|
||||
}
|
||||
if object.Signatures[0].Header.Algorithm != string(ticketSigningAlgorithm) {
|
||||
return Ticket{}, xerrors.Errorf("expected ticket signing algorithm to be %q, got %q", ticketSigningAlgorithm, object.Signatures[0].Header.Algorithm)
|
||||
}
|
||||
|
||||
output, err := object.Verify(p.TicketSigningKey)
|
||||
if err != nil {
|
||||
return Ticket{}, xerrors.Errorf("verify JWS: %w", err)
|
||||
}
|
||||
|
||||
var ticket Ticket
|
||||
err = json.Unmarshal(output, &ticket)
|
||||
if err != nil {
|
||||
return Ticket{}, xerrors.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if ticket.Expiry < time.Now().Unix() {
|
||||
return Ticket{}, xerrors.New("ticket expired")
|
||||
}
|
||||
|
||||
return ticket, nil
|
||||
}
|
302
coderd/workspaceapps/ticket_test.go
Normal file
302
coderd/workspaceapps/ticket_test.go
Normal file
@ -0,0 +1,302 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
)
|
||||
|
||||
func Test_TicketMatchesRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req workspaceapps.Request
|
||||
ticket workspaceapps.Ticket
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DifferentAccessMethod",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
},
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentUsernameOrID",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
},
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "bar",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentWorkspaceNameOrID",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
},
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "baz",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentAgentNameOrID",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
},
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "qux",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DifferentAppSlugOrPort",
|
||||
req: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
},
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "quux",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, c.want, c.ticket.MatchesRequest(c.req))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GenerateTicket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, 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",
|
||||
|
||||
Expiry: 0,
|
||||
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
|
||||
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
|
||||
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
|
||||
AppURL: "http://127.0.0.1:8080",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ticket, err := provider.ParseTicket(ticketStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.InDelta(t, time.Now().Unix(), ticket.Expiry, time.Minute.Seconds())
|
||||
})
|
||||
|
||||
future := time.Now().Add(time.Hour).Unix()
|
||||
cases := []struct {
|
||||
name string
|
||||
ticket workspaceapps.Ticket
|
||||
parseErrContains string
|
||||
}{
|
||||
{
|
||||
name: "OK1",
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
|
||||
Expiry: future,
|
||||
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
|
||||
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
|
||||
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
|
||||
AppURL: "http://127.0.0.1:8080",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OK2",
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
UsernameOrID: "oof",
|
||||
WorkspaceNameOrID: "rab",
|
||||
AgentNameOrID: "zab",
|
||||
AppSlugOrPort: "xuq",
|
||||
|
||||
Expiry: future,
|
||||
UserID: uuid.MustParse("6fa684a3-11aa-49fd-8512-ab527bd9b900"),
|
||||
WorkspaceID: uuid.MustParse("b2d816cc-505c-441d-afdf-dae01781bc0b"),
|
||||
AgentID: uuid.MustParse("6c4396e1-af88-4a8a-91a3-13ea54fc29fb"),
|
||||
AppURL: "http://localhost:9090",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Expired",
|
||||
ticket: workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodSubdomain,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
|
||||
Expiry: time.Now().Add(-time.Hour).Unix(),
|
||||
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
|
||||
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
|
||||
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
|
||||
AppURL: "http://127.0.0.1:8080",
|
||||
},
|
||||
parseErrContains: "ticket expired",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
str, err := provider.GenerateTicket(c.ticket)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tickets aren't deterministic as they have a random nonce, so we
|
||||
// can't compare them directly.
|
||||
|
||||
ticket, err := provider.ParseTicket(str)
|
||||
if c.parseErrContains != "" {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, c.parseErrContains)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.ticket, ticket)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The ParseTicket fn is tested quite thoroughly in the GenerateTicket test.
|
||||
func Test_ParseTicket(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, coderdtest.AppSigningKey)
|
||||
|
||||
t.Run("InvalidJWS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ticket, err := provider.ParseTicket("invalid")
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "parse JWS")
|
||||
require.Equal(t, workspaceapps.Ticket{}, ticket)
|
||||
})
|
||||
|
||||
t.Run("VerifySignature", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a valid ticket using a different key.
|
||||
otherKey, err := hex.DecodeString("62656566646561646265656664656164626565666465616462656566646561646265656664656164626565666465616462656566646561646265656664656164")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, coderdtest.AppSigningKey, otherKey)
|
||||
require.Len(t, otherKey, 64)
|
||||
|
||||
otherProvider := workspaceapps.New(slogtest.Make(t, nil), nil, nil, nil, nil, nil, otherKey)
|
||||
|
||||
ticketStr, err := otherProvider.GenerateTicket(workspaceapps.Ticket{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
UsernameOrID: "foo",
|
||||
WorkspaceNameOrID: "bar",
|
||||
AgentNameOrID: "baz",
|
||||
AppSlugOrPort: "qux",
|
||||
|
||||
Expiry: time.Now().Add(time.Hour).Unix(),
|
||||
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
|
||||
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
|
||||
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
|
||||
AppURL: "http://127.0.0.1:8080",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the ticket is invalid.
|
||||
ticket, err := provider.ParseTicket(ticketStr)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "verify JWS")
|
||||
require.Equal(t, workspaceapps.Ticket{}, ticket)
|
||||
})
|
||||
|
||||
t.Run("InvalidBody", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a signature for an invalid body.
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: provider.TicketSigningKey}, nil)
|
||||
require.NoError(t, err)
|
||||
signedObject, err := signer.Sign([]byte("hi"))
|
||||
require.NoError(t, err)
|
||||
serialized, err := signedObject.CompactSerialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
ticket, err := provider.ParseTicket(serialized)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "unmarshal payload")
|
||||
require.Equal(t, workspaceapps.Ticket{}, ticket)
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user