chore: ticket provider interface (#6915)

This commit is contained in:
Dean Sheather
2023-04-04 10:59:41 +10:00
committed by GitHub
parent e0f7f011d8
commit 34593e3944
16 changed files with 668 additions and 678 deletions

View File

@ -1,387 +0,0 @@
package workspaceapps
import (
"context"
"database/sql"
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/httpapi"
"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) {
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
// // dangerousSystemCtx is only used for database calls. The actual authentication
// // logic is handled in Provider.authorizeWorkspaceApp which directly checks the actor's
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(r.Context())
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 {
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
}
}
}
// 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{
Request: appReq,
}
// 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.DeploymentValues.DisableSessionExpiryRefresh.Value(),
// Optional is true to allow for public apps. If an authorization check
// fails and the user is not authenticated, they will be redirected to
// the login page using code below (not the redirect from the
// middleware itself).
Optional: true,
})
if !ok {
return nil, false
}
// 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 err != nil {
p.writeWorkspaceApp500(rw, r, &appReq, err, "get app details from database")
return nil, false
}
ticket.UserID = dbReq.User.ID
ticket.WorkspaceID = dbReq.Workspace.ID
ticket.AgentID = dbReq.Agent.ID
ticket.AppURL = dbReq.AppURL
// 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.verifyAuthz(rw, r, authz, dbReq)
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.
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)
u := *p.AccessURL
u.Path = "/api/v2/applications/auth-redirect"
q := u.Query()
q.Add(RedirectURIQueryParam, redirectURI.String())
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
case AccessMethodTerminal:
// Return an error.
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) {
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
}
func (p *Provider) authorizeRequest(ctx context.Context, roles *httpmw.Authorization, dbReq *databaseRequest) (bool, error) {
accessMethod := dbReq.AccessMethod
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.
sharingLevel := dbReq.AppSharingLevel
if isPathApp && !p.DeploymentValues.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 &&
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, rbacAction, rbacResource)
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:
// 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.
return true, nil
}
// No checks were successful.
return false, nil
}
// 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) 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{
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(),
})
}
// 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(),
})
}

293
coderd/workspaceapps/db.go Normal file
View File

@ -0,0 +1,293 @@
package workspaceapps
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/url"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
// DBTokenProvider provides authentication and authorization for workspace apps
// by querying the database if the request is missing a valid token.
type DBTokenProvider struct {
Logger slog.Logger
// AccessURL is the main dashboard access URL for error pages.
AccessURL *url.URL
Authorizer rbac.Authorizer
Database database.Store
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
WorkspaceAgentInactiveTimeout time.Duration
TokenSigningKey []byte
}
var _ SignedTokenProvider = &DBTokenProvider{}
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, tokenSigningKey []byte) SignedTokenProvider {
if len(tokenSigningKey) != 64 {
panic("token signing key must be 64 bytes")
}
if workspaceAgentInactiveTimeout == 0 {
workspaceAgentInactiveTimeout = 1 * time.Minute
}
return &DBTokenProvider{
Logger: log,
AccessURL: accessURL,
Authorizer: authz,
Database: db,
DeploymentValues: cfg,
OAuth2Configs: oauth2Cfgs,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
TokenSigningKey: tokenSigningKey,
}
}
func (p *DBTokenProvider) TokenFromRequest(r *http.Request) (*SignedToken, bool) {
// Get the existing token from the request.
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
if err == nil {
token, err := ParseToken(p.TokenSigningKey, tokenCookie.Value)
if err == nil {
req := token.Request.Normalize()
err := req.Validate()
if err == nil {
// The request has a valid signed app token, which is a valid
// token signed by us. The caller must check that it matches
// the request.
return &token, true
}
}
}
return nil, false
}
// ResolveRequest takes an app request, checks if it's valid and authenticated,
// and returns a token with details about the app.
func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, string, bool) {
// nolint:gocritic // We need to make a number of database calls. Setting a system context here
// // is simpler than calling dbauthz.AsSystemRestricted on every call.
// // dangerousSystemCtx is only used for database calls. The actual authentication
// // logic is handled in Provider.authorizeWorkspaceApp which directly checks the actor's
// // permissions.
dangerousSystemCtx := dbauthz.AsSystemRestricted(ctx)
appReq = appReq.Normalize()
err := appReq.Validate()
if err != nil {
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "invalid app request")
return nil, "", false
}
token := SignedToken{
Request: appReq,
}
// 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.DeploymentValues.DisableSessionExpiryRefresh.Value(),
// Optional is true to allow for public apps. If an authorization check
// fails and the user is not authenticated, they will be redirected to
// the login page using code below (not the redirect from the
// middleware itself).
Optional: true,
})
if !ok {
return nil, "", false
}
// Lookup workspace app details from DB.
dbReq, err := appReq.getDatabase(dangerousSystemCtx, p.Database)
if xerrors.Is(err, sql.ErrNoRows) {
WriteWorkspaceApp404(p.Logger, p.AccessURL, rw, r, &appReq, err.Error())
return nil, "", false
} else if err != nil {
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "get app details from database")
return nil, "", false
}
token.UserID = dbReq.User.ID
token.WorkspaceID = dbReq.Workspace.ID
token.AgentID = dbReq.Agent.ID
token.AppURL = dbReq.AppURL
// 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, err := p.authorizeRequest(r.Context(), authz, dbReq)
if err != nil {
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "verify authz")
return nil, "", false
}
if !authed {
if apiKey != nil {
// The request has a valid API key but insufficient permissions.
WriteWorkspaceApp404(p.Logger, p.AccessURL, rw, r, &appReq, "insufficient permissions")
return nil, "", false
}
// Redirect to login as they don't have permission to access the app
// and they aren't signed in.
switch appReq.AccessMethod {
case AccessMethodPath:
// TODO(@deansheather): this doesn't work on moons
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
case AccessMethodSubdomain:
// Redirect to the app auth redirect endpoint with a valid redirect
// URI.
redirectURI := *r.URL
redirectURI.Scheme = p.AccessURL.Scheme
redirectURI.Host = httpapi.RequestHost(r)
u := *p.AccessURL
u.Path = "/api/v2/applications/auth-redirect"
q := u.Query()
q.Add(RedirectURIQueryParam, redirectURI.String())
u.RawQuery = q.Encode()
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
case AccessMethodTerminal:
// Return an error.
httpapi.ResourceNotFound(rw)
}
return nil, "", false
}
// Check that the agent is online.
agentStatus := dbReq.Agent.Status(p.WorkspaceAgentInactiveTimeout)
if agentStatus.Status != database.WorkspaceAgentStatusConnected {
WriteWorkspaceAppOffline(p.Logger, p.AccessURL, rw, r, &appReq, fmt.Sprintf("Agent state is %q, not %q", agentStatus.Status, database.WorkspaceAgentStatusConnected))
return nil, "", false
}
// Check that the app is healthy.
if dbReq.AppHealth != "" && dbReq.AppHealth != database.WorkspaceAppHealthDisabled && dbReq.AppHealth != database.WorkspaceAppHealthHealthy {
WriteWorkspaceAppOffline(p.Logger, p.AccessURL, rw, r, &appReq, fmt.Sprintf("App health is %q, not %q", dbReq.AppHealth, database.WorkspaceAppHealthHealthy))
return nil, "", false
}
// As a sanity check, ensure the token we just made is valid for this
// request.
if !token.MatchesRequest(appReq) {
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, nil, "fresh token does not match request")
return nil, "", false
}
// Sign the token.
token.Expiry = time.Now().Add(DefaultTokenExpiry)
tokenStr, err := GenerateToken(p.TokenSigningKey, token)
if err != nil {
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "generate token")
return nil, "", false
}
return &token, tokenStr, true
}
func (p *DBTokenProvider) authorizeRequest(ctx context.Context, roles *httpmw.Authorization, dbReq *databaseRequest) (bool, error) {
accessMethod := dbReq.AccessMethod
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.
sharingLevel := dbReq.AppSharingLevel
if isPathApp && !p.DeploymentValues.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 &&
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, rbacAction, rbacResource)
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:
// 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.
return true, nil
}
// No checks were successful.
return false, nil
}

View File

@ -218,8 +218,8 @@ func Test_ResolveRequest(t *testing.T) {
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.
// Try resolving a request for each app as the owner, without a
// token, then use the token to resolve each app.
for _, app := range allApps {
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
@ -235,8 +235,8 @@ func Test_ResolveRequest(t *testing.T) {
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)
// Try resolving the request without a token.
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
w := rw.Result()
if !assert.True(t, ok) {
dump, err := httputil.DumpResponse(w, true)
@ -246,35 +246,41 @@ func Test_ResolveRequest(t *testing.T) {
}
_ = w.Body.Close()
require.Equal(t, &workspaceapps.Ticket{
require.Equal(t, &workspaceapps.SignedToken{
Request: req,
Expiry: ticket.Expiry, // ignored to avoid flakiness
Expiry: token.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())
}, token)
require.NotZero(t, token.Expiry)
require.WithinDuration(t, time.Now().Add(workspaceapps.DefaultTokenExpiry), token.Expiry, time.Minute)
// Check that the ticket was set in the response and is valid.
// Check that the token 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, codersdk.DevURLSignedAppTokenCookie, cookie.Name)
require.Equal(t, req.BasePath, cookie.Path)
parsedTicket, err := api.WorkspaceAppsProvider.ParseTicket(cookie.Value)
parsedToken, err := workspaceapps.ParseToken(api.AppSigningKey, cookie.Value)
require.NoError(t, err)
require.Equal(t, ticket, &parsedTicket)
// normalize expiry
require.WithinDuration(t, token.Expiry, parsedToken.Expiry, 2*time.Second)
parsedToken.Expiry = token.Expiry
require.Equal(t, token, &parsedToken)
// Try resolving the request with the ticket only.
// Try resolving the request with the token only.
rw = httptest.NewRecorder()
r = httptest.NewRequest("GET", "/app", nil)
r.AddCookie(cookie)
secondTicket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
secondToken, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.True(t, ok)
require.Equal(t, ticket, secondTicket)
// normalize expiry
require.WithinDuration(t, token.Expiry, secondToken.Expiry, 2*time.Second)
secondToken.Expiry = token.Expiry
require.Equal(t, token, secondToken)
}
})
}
@ -298,18 +304,18 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
w := rw.Result()
_ = w.Body.Close()
if app == appNameOwner {
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
require.NotZero(t, w.StatusCode)
require.Equal(t, http.StatusNotFound, w.StatusCode)
return
}
require.True(t, ok)
require.NotNil(t, ticket)
require.NotNil(t, token)
require.Zero(t, w.StatusCode)
}
})
@ -330,11 +336,11 @@ func Test_ResolveRequest(t *testing.T) {
t.Log("app", app)
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
w := rw.Result()
if app != appNamePublic {
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
require.NotZero(t, rw.Code)
require.NotEqual(t, http.StatusOK, rw.Code)
} else {
@ -344,7 +350,7 @@ func Test_ResolveRequest(t *testing.T) {
t.Log(string(dump))
return
}
require.NotNil(t, ticket)
require.NotNil(t, token)
if rw.Code != 0 && rw.Code != http.StatusOK {
t.Fatalf("expected 200 (or unset) response code, got %d", rw.Code)
}
@ -361,9 +367,9 @@ func Test_ResolveRequest(t *testing.T) {
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
})
t.Run("SplitWorkspaceAndAgent", func(t *testing.T) {
@ -435,7 +441,7 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
w := rw.Result()
if !assert.Equal(t, c.ok, ok) {
dump, err := httputil.DumpResponse(w, true)
@ -444,23 +450,23 @@ func Test_ResolveRequest(t *testing.T) {
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)
require.NotNil(t, token)
require.Equal(t, token.WorkspaceNameOrID, c.workspace)
require.Equal(t, token.AgentNameOrID, c.agent)
require.Equal(t, token.WorkspaceID, workspace.ID)
require.Equal(t, token.AgentID, agentID)
} else {
require.Nil(t, ticket)
require.Nil(t, token)
}
_ = w.Body.Close()
})
}
})
t.Run("TicketDoesNotMatchRequest", func(t *testing.T) {
t.Run("TokenDoesNotMatchRequest", func(t *testing.T) {
t.Parallel()
badTicket := workspaceapps.Ticket{
badToken := workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -470,13 +476,13 @@ func Test_ResolveRequest(t *testing.T) {
// App name differs
AppSlugOrPort: appNamePublic,
},
Expiry: time.Now().Add(time.Minute).Unix(),
Expiry: time.Now().Add(time.Minute),
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}
badTicketStr, err := api.WorkspaceAppsProvider.GenerateTicket(badTicket)
badTokenStr, err := workspaceapps.GenerateToken(api.AppSigningKey, badToken)
require.NoError(t, err)
req := workspaceapps.Request{
@ -493,28 +499,28 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
r.AddCookie(&http.Cookie{
Name: codersdk.DevURLSessionTicketCookie,
Value: badTicketStr,
Name: codersdk.DevURLSignedAppTokenCookie,
Value: badTokenStr,
})
// Even though the ticket is invalid, we should still perform request
// resolution.
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
// Even though the token is invalid, we should still perform request
// resolution without failure since we'll just ignore the bad token.
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.True(t, ok)
require.NotNil(t, ticket)
require.Equal(t, appNameOwner, ticket.AppSlugOrPort)
require.NotNil(t, token)
require.Equal(t, appNameOwner, token.AppSlugOrPort)
// Cookie should be set in response, and it should be a different
// ticket.
// token.
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.Equal(t, cookies[0].Name, codersdk.DevURLSignedAppTokenCookie)
require.NotEqual(t, cookies[0].Value, badTokenStr)
parsedToken, err := workspaceapps.ParseToken(api.AppSigningKey, cookies[0].Value)
require.NoError(t, err)
require.Equal(t, appNameOwner, parsedTicket.AppSlugOrPort)
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
})
t.Run("PortPathBlocked", func(t *testing.T) {
@ -533,9 +539,9 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
})
t.Run("PortSubdomain", func(t *testing.T) {
@ -554,10 +560,10 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, 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)
require.Equal(t, req.AppSlugOrPort, token.AppSlugOrPort)
require.Equal(t, "http://127.0.0.1:9090", token.AppURL)
})
t.Run("Terminal", func(t *testing.T) {
@ -573,15 +579,15 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, 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)
require.Equal(t, req.AccessMethod, token.AccessMethod)
require.Equal(t, req.BasePath, token.BasePath)
require.Empty(t, token.UsernameOrID)
require.Empty(t, token.WorkspaceNameOrID)
require.Equal(t, req.AgentNameOrID, token.Request.AgentNameOrID)
require.Empty(t, token.AppSlugOrPort)
require.Empty(t, token.AppURL)
})
t.Run("InsufficientPermissions", func(t *testing.T) {
@ -600,9 +606,9 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, secondUserClient.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
})
t.Run("UserNotFound", func(t *testing.T) {
@ -620,9 +626,9 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
})
t.Run("RedirectSubdomainAuth", func(t *testing.T) {
@ -641,9 +647,9 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/some-path", nil)
r.Host = "app.com"
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok)
require.Nil(t, ticket)
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()
@ -681,9 +687,9 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok, "request succeeded even though agent is not connected")
require.Nil(t, ticket)
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()
@ -735,9 +741,9 @@ func Test_ResolveRequest(t *testing.T) {
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
token, ok := workspaceapps.ResolveRequest(api.Logger, api.AccessURL, api.WorkspaceAppsProvider, rw, r, req)
require.False(t, ok, "request succeeded even though app is unhealthy")
require.Nil(t, ticket)
require.Nil(t, token)
w := rw.Result()
defer w.Body.Close()

View File

@ -0,0 +1,85 @@
package workspaceapps
import (
"net/http"
"net/url"
"cdr.dev/slog"
"github.com/coder/coder/site"
)
// 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 WriteWorkspaceApp404(log slog.Logger, accessURL *url.URL, rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
if appReq != nil {
slog.Helper()
log.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: 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 WriteWorkspaceApp500(log slog.Logger, accessURL *url.URL, rw http.ResponseWriter, r *http.Request, appReq *Request, err error, msg string) {
ctx := r.Context()
if appReq != nil {
slog.Helper()
ctx = 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),
)
}
log.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: 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 WriteWorkspaceAppOffline(log slog.Logger, accessURL *url.URL, rw http.ResponseWriter, r *http.Request, appReq *Request, msg string) {
if appReq != nil {
slog.Helper()
log.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: accessURL.String(),
})
}

View File

@ -1,48 +1,72 @@
package workspaceapps
import (
"context"
"net/http"
"net/url"
"time"
"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
const (
// TODO(@deansheather): configurable expiry
DefaultTokenExpiry = time.Minute
AccessURL *url.URL
Authorizer rbac.Authorizer
Database database.Store
DeploymentValues *codersdk.DeploymentValues
OAuth2Configs *httpmw.OAuth2Configs
WorkspaceAgentInactiveTimeout time.Duration
TicketSigningKey []byte
// 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 calls TokenProvider to use an existing signed app token in the
// request or issue a new one. If it returns a newly minted token, it sets the
// cookie for you.
func ResolveRequest(log slog.Logger, accessURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
appReq = appReq.Normalize()
err := appReq.Validate()
if err != nil {
WriteWorkspaceApp500(log, accessURL, rw, r, &appReq, err, "invalid app request")
return nil, false
}
token, ok := p.TokenFromRequest(r)
if ok && token.MatchesRequest(appReq) {
// The request has a valid signed app token and it matches the request.
return token, true
}
token, tokenStr, ok := p.CreateToken(r.Context(), rw, r, appReq)
if !ok {
return nil, false
}
// Write the signed app token cookie. We always want this to apply to the
// current hostname (even for subdomain apps, without any wildcard
// shenanigans, because the token is only valid for a single app).
http.SetCookie(rw, &http.Cookie{
Name: codersdk.DevURLSignedAppTokenCookie,
Value: tokenStr,
Path: appReq.BasePath,
Expires: token.Expiry,
})
return token, true
}
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,
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
TicketSigningKey: ticketSigningKey,
}
// SignedTokenProvider provides signed workspace app tokens (aka. app tickets).
type SignedTokenProvider interface {
// TokenFromRequest returns a parsed token from the request. If the request
// does not contain a signed app token or is is invalid (expired, invalid
// signature, etc.), it returns false.
TokenFromRequest(r *http.Request) (*SignedToken, bool)
// CreateToken mints a new token for the given app request. It uses the
// long-lived session token in the HTTP request to authenticate and
// authorize the client for the given workspace app. The token is returned
// in struct and string form. The string form should be written as a cookie.
//
// If the request is invalid or the user is not authorized to access the
// app, false is returned. An error page is written to the response writer
// in this case.
CreateToken(ctx context.Context, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, string, bool)
}

View File

@ -44,6 +44,25 @@ type Request struct {
AppSlugOrPort string `json:"app_slug_or_port"`
}
// Normalize replaces WorkspaceAndAgent with WorkspaceNameOrID and
// AgentNameOrID. This must be called before Validate.
func (r Request) Normalize() Request {
req := r
if req.WorkspaceAndAgent != "" {
// workspace.agent
workspaceAndAgent := strings.SplitN(req.WorkspaceAndAgent, ".", 2)
req.WorkspaceAndAgent = ""
req.WorkspaceNameOrID = workspaceAndAgent[0]
if len(workspaceAndAgent) > 1 {
req.AgentNameOrID = workspaceAndAgent[1]
}
}
return req
}
// Validate ensures the request is correct and contains the necessary
// parameters.
func (r Request) Validate() error {
switch r.AccessMethod {
case AccessMethodPath, AccessMethodSubdomain, AccessMethodTerminal:
@ -54,8 +73,12 @@ func (r Request) Validate() error {
return xerrors.New("base path is required")
}
if r.WorkspaceAndAgent != "" {
return xerrors.New("dev error: appReq.Validate() called before appReq.Normalize()")
}
if r.AccessMethod == AccessMethodTerminal {
if r.UsernameOrID != "" || r.WorkspaceAndAgent != "" || r.WorkspaceNameOrID != "" || r.AppSlugOrPort != "" {
if r.UsernameOrID != "" || 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")
}
@ -75,25 +98,16 @@ func (r Request) Validate() error {
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.
// different user and potentially reusing a token.
//
// This is also mitigated by storing the workspace/agent ID in the
// ticket, but we block it here to be double safe.
// token, 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 == "" {
if r.WorkspaceNameOrID == "" {
return xerrors.New("workspace name or ID is required")
}
if r.AppSlugOrPort == "" {

View File

@ -117,7 +117,7 @@ func Test_RequestValidate(t *testing.T) {
errContains: `username cannot be "me"`,
},
{
name: "InvalidWorkspaceAndAgent/Empty1",
name: "InvalidWorkspaceAndAgent/EmptyWorkspace",
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/",
@ -125,53 +125,7 @@ func Test_RequestValidate(t *testing.T) {
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",
errContains: "workspace name or ID is required",
},
{
name: "NoWorkspaceNameOrID",
@ -261,12 +215,13 @@ func Test_RequestValidate(t *testing.T) {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
err := c.req.Validate()
req := c.req.Normalize()
err := req.Validate()
if c.errContains == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), c.errContains)
require.ErrorContains(t, err, c.errContains)
}
})
}

View File

@ -9,26 +9,26 @@ import (
"gopkg.in/square/go-jose.v2"
)
const ticketSigningAlgorithm = jose.HS512
const tokenSigningAlgorithm = 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
// SignedToken is the struct data contained inside a workspace app JWE. It
// contains the details of the workspace app that the token is valid for to
// avoid database queries.
//
// The JSON field names are short to reduce the size of the ticket.
type Ticket struct {
type SignedToken struct {
// Request details.
Request `json:"request"`
// Trusted resolved details.
Expiry int64 `json:"expiry"` // set by GenerateTicket if unset
Expiry time.Time `json:"expiry"` // set by GenerateToken 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 {
// MatchesRequest returns true if the token matches the request. Any token that
// does not match the request should be considered invalid.
func (t SignedToken) MatchesRequest(req Request) bool {
return t.AccessMethod == req.AccessMethod &&
t.BasePath == req.BasePath &&
t.UsernameOrID == req.UsernameOrID &&
@ -37,20 +37,21 @@ func (t Ticket) MatchesRequest(req Request) bool {
t.AppSlugOrPort == req.AppSlugOrPort
}
func (p *Provider) GenerateTicket(payload Ticket) (string, error) {
if payload.Expiry == 0 {
payload.Expiry = time.Now().Add(TicketExpiry).Unix()
// GenerateToken generates a signed workspace app token with the given key and
// payload. If the payload doesn't have an expiry, it will be set to the current
// time plus the default expiry.
func GenerateToken(key []byte, payload SignedToken) (string, error) {
if payload.Expiry.IsZero() {
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
}
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,
Algorithm: tokenSigningAlgorithm,
Key: key,
}, nil)
if err != nil {
return "", xerrors.Errorf("create signer: %w", err)
@ -69,31 +70,33 @@ func (p *Provider) GenerateTicket(payload Ticket) (string, error) {
return serialized, nil
}
func (p *Provider) ParseTicket(ticketStr string) (Ticket, error) {
object, err := jose.ParseSigned(ticketStr)
// ParseToken parses a signed workspace app token with the given key and returns
// the payload. If the token is invalid or expired, an error is returned.
func ParseToken(key []byte, str string) (SignedToken, error) {
object, err := jose.ParseSigned(str)
if err != nil {
return Ticket{}, xerrors.Errorf("parse JWS: %w", err)
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
}
if len(object.Signatures) != 1 {
return Ticket{}, xerrors.New("expected 1 signature")
return SignedToken{}, 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)
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
}
output, err := object.Verify(p.TicketSigningKey)
output, err := object.Verify(key)
if err != nil {
return Ticket{}, xerrors.Errorf("verify JWS: %w", err)
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
}
var ticket Ticket
err = json.Unmarshal(output, &ticket)
var tok SignedToken
err = json.Unmarshal(output, &tok)
if err != nil {
return Ticket{}, xerrors.Errorf("unmarshal payload: %w", err)
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
}
if ticket.Expiry < time.Now().Unix() {
return Ticket{}, xerrors.New("ticket expired")
if tok.Expiry.Before(time.Now()) {
return SignedToken{}, xerrors.New("signed app token expired")
}
return ticket, nil
return tok, nil
}

View File

@ -9,20 +9,18 @@ import (
"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) {
func Test_TokenMatchesRequest(t *testing.T) {
t.Parallel()
cases := []struct {
name string
req workspaceapps.Request
ticket workspaceapps.Ticket
want bool
name string
req workspaceapps.Request
token workspaceapps.SignedToken
want bool
}{
{
name: "OK",
@ -34,7 +32,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -51,7 +49,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
},
@ -63,7 +61,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
req: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -78,7 +76,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
BasePath: "/app",
UsernameOrID: "foo",
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -95,7 +93,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
UsernameOrID: "foo",
WorkspaceNameOrID: "bar",
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -114,7 +112,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
WorkspaceNameOrID: "bar",
AgentNameOrID: "baz",
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -135,7 +133,7 @@ func Test_TicketMatchesRequest(t *testing.T) {
AgentNameOrID: "baz",
AppSlugOrPort: "qux",
},
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -155,20 +153,18 @@ func Test_TicketMatchesRequest(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, c.want, c.ticket.MatchesRequest(c.req))
require.Equal(t, c.want, c.token.MatchesRequest(c.req))
})
}
}
func Test_GenerateTicket(t *testing.T) {
func Test_GenerateToken(t *testing.T) {
t.Parallel()
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{
tokenStr, err := workspaceapps.GenerateToken(coderdtest.AppSigningKey, workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -178,7 +174,7 @@ func Test_GenerateTicket(t *testing.T) {
AppSlugOrPort: "qux",
},
Expiry: 0,
Expiry: time.Time{},
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
@ -186,21 +182,21 @@ func Test_GenerateTicket(t *testing.T) {
})
require.NoError(t, err)
ticket, err := provider.ParseTicket(ticketStr)
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, tokenStr)
require.NoError(t, err)
require.InDelta(t, time.Now().Unix(), ticket.Expiry, time.Minute.Seconds())
require.WithinDuration(t, time.Now().Add(time.Minute), token.Expiry, 15*time.Second)
})
future := time.Now().Add(time.Hour).Unix()
future := time.Now().Add(time.Hour)
cases := []struct {
name string
ticket workspaceapps.Ticket
token workspaceapps.SignedToken
parseErrContains string
}{
{
name: "OK1",
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -219,7 +215,7 @@ func Test_GenerateTicket(t *testing.T) {
},
{
name: "OK2",
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
@ -238,7 +234,7 @@ func Test_GenerateTicket(t *testing.T) {
},
{
name: "Expired",
ticket: workspaceapps.Ticket{
token: workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
@ -248,13 +244,13 @@ func Test_GenerateTicket(t *testing.T) {
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(-time.Hour).Unix(),
Expiry: time.Now().Add(-time.Hour),
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",
parseErrContains: "token expired",
},
}
@ -264,51 +260,51 @@ func Test_GenerateTicket(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
str, err := provider.GenerateTicket(c.ticket)
str, err := workspaceapps.GenerateToken(coderdtest.AppSigningKey, c.token)
require.NoError(t, err)
// Tickets aren't deterministic as they have a random nonce, so we
// Tokens aren't deterministic as they have a random nonce, so we
// can't compare them directly.
ticket, err := provider.ParseTicket(str)
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, 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)
// normalize the expiry
require.WithinDuration(t, c.token.Expiry, token.Expiry, 10*time.Second)
c.token.Expiry = token.Expiry
require.Equal(t, c.token, token)
}
})
}
}
// The ParseTicket fn is tested quite thoroughly in the GenerateTicket test.
func Test_ParseTicket(t *testing.T) {
// The ParseToken fn is tested quite thoroughly in the GenerateToken test as
// well.
func Test_ParseToken(t *testing.T) {
t.Parallel()
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()
ticket, err := provider.ParseTicket("invalid")
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, "invalid")
require.Error(t, err)
require.ErrorContains(t, err, "parse JWS")
require.Equal(t, workspaceapps.Ticket{}, ticket)
require.Equal(t, workspaceapps.SignedToken{}, token)
})
t.Run("VerifySignature", func(t *testing.T) {
t.Parallel()
// Create a valid ticket using a different key.
// Create a valid token 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, time.Minute, otherKey)
ticketStr, err := otherProvider.GenerateTicket(workspaceapps.Ticket{
tokenStr, err := workspaceapps.GenerateToken(otherKey, workspaceapps.SignedToken{
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
@ -318,7 +314,7 @@ func Test_ParseTicket(t *testing.T) {
AppSlugOrPort: "qux",
},
Expiry: time.Now().Add(time.Hour).Unix(),
Expiry: time.Now().Add(time.Hour),
UserID: uuid.MustParse("b1530ba9-76f3-415e-b597-4ddd7cd466a4"),
WorkspaceID: uuid.MustParse("1e6802d3-963e-45ac-9d8c-bf997016ffed"),
AgentID: uuid.MustParse("9ec18681-d2c9-4c9e-9186-f136efb4edbe"),
@ -326,27 +322,27 @@ func Test_ParseTicket(t *testing.T) {
})
require.NoError(t, err)
// Verify the ticket is invalid.
ticket, err := provider.ParseTicket(ticketStr)
// Verify the token is invalid.
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, tokenStr)
require.Error(t, err)
require.ErrorContains(t, err, "verify JWS")
require.Equal(t, workspaceapps.Ticket{}, ticket)
require.Equal(t, workspaceapps.SignedToken{}, token)
})
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)
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSigningKey}, 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)
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, serialized)
require.Error(t, err)
require.ErrorContains(t, err, "unmarshal payload")
require.Equal(t, workspaceapps.Ticket{}, ticket)
require.Equal(t, workspaceapps.SignedToken{}, token)
})
}