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:
Dean Sheather 2023-03-08 06:38:11 +11:00 committed by GitHub
parent f8494d2bac
commit 1bdd2abed7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 2809 additions and 969 deletions

View File

@ -10,6 +10,7 @@ import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
@ -587,19 +588,62 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co
defer options.Pubsub.Close()
}
deploymentID, err := options.Database.GetDeploymentID(ctx)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
return xerrors.Errorf("get deployment id: %w", err)
}
if deploymentID == "" {
deploymentID = uuid.NewString()
err = options.Database.InsertDeploymentID(ctx, deploymentID)
var deploymentID string
err = options.Database.InTx(func(tx database.Store) error {
// This will block until the lock is acquired, and will be
// automatically released when the transaction ends.
err := tx.AcquireLock(ctx, database.LockIDDeploymentSetup)
if err != nil {
return xerrors.Errorf("set deployment id: %w", err)
return xerrors.Errorf("acquire lock: %w", err)
}
deploymentID, err = tx.GetDeploymentID(ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get deployment id: %w", err)
}
if deploymentID == "" {
deploymentID = uuid.NewString()
err = tx.InsertDeploymentID(ctx, deploymentID)
if err != nil {
return xerrors.Errorf("set deployment id: %w", err)
}
}
// Read the app signing key from the DB. We store it hex
// encoded since the config table uses strings for the value and
// we don't want to deal with automatic encoding issues.
appSigningKeyStr, err := tx.GetAppSigningKey(ctx)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get app signing key: %w", err)
}
if appSigningKeyStr == "" {
// Generate 64 byte secure random string.
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return xerrors.Errorf("generate fresh app signing key: %w", err)
}
appSigningKeyStr = hex.EncodeToString(b)
err = tx.InsertAppSigningKey(ctx, appSigningKeyStr)
if err != nil {
return xerrors.Errorf("insert freshly generated app signing key to database: %w", err)
}
}
appSigningKey, err := hex.DecodeString(appSigningKeyStr)
if err != nil {
return xerrors.Errorf("decode app signing key from database as hex: %w", err)
}
if len(appSigningKey) != 64 {
return xerrors.Errorf("app signing key must be 64 bytes, key in database is %d bytes", len(appSigningKey))
}
options.AppSigningKey = appSigningKey
return nil
}, nil)
if err != nil {
return err
}
// Disable telemetry if the in-memory database is used unless explicitly defined!

View File

@ -56,6 +56,7 @@ import (
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/updatecheck"
"github.com/coder/coder/coderd/util/slice"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/coderd/wsconncache"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionerd/proto"
@ -120,6 +121,9 @@ type Options struct {
SwaggerEndpoint bool
SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error
TemplateScheduleStore schedule.TemplateScheduleStore
// AppSigningKey denotes the symmetric key to use for signing app tickets.
// The key must be 64 bytes long.
AppSigningKey []byte
// APIRateLimit is the minutely throughput rate limit per user or ip.
// Setting a rate limit <0 will disable the rate limiter across the entire
@ -214,6 +218,9 @@ func New(options *Options) *API {
if options.TemplateScheduleStore == nil {
options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore()
}
if len(options.AppSigningKey) != 64 {
panic("coderd: AppSigningKey must be 64 bytes long")
}
siteCacheDir := options.CacheDir
if siteCacheDir != "" {
@ -236,6 +243,11 @@ func New(options *Options) *API {
// static files since it only affects browsers.
staticHandler = httpmw.HSTS(staticHandler, options.StrictTransportSecurityCfg)
oauthConfigs := &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
OIDC: options.OIDCConfig,
}
r := chi.NewRouter()
ctx, cancel := context.WithCancel(context.Background())
api := &API{
@ -250,6 +262,15 @@ func New(options *Options) *API {
Authorizer: options.Authorizer,
Logger: options.Logger,
},
WorkspaceAppsProvider: workspaceapps.New(
options.Logger.Named("workspaceapps"),
options.AccessURL,
options.Authorizer,
options.Database,
options.DeploymentConfig,
oauthConfigs,
options.AppSigningKey,
),
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
TemplateScheduleStore: atomic.Pointer[schedule.TemplateScheduleStore]{},
@ -266,12 +287,8 @@ func New(options *Options) *API {
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
api.TailnetCoordinator.Store(&options.TailnetCoordinator)
oauthConfigs := &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
OIDC: options.OIDCConfig,
}
apiKeyMiddleware := httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: false,
@ -279,7 +296,7 @@ func New(options *Options) *API {
Optional: false,
})
// Same as above but it redirects to the login page.
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: true,
@ -305,23 +322,9 @@ func New(options *Options) *API {
httpmw.Prometheus(options.PrometheusRegistry),
// handleSubdomainApplications checks if the first subdomain is a valid
// app URL. If it is, it will serve that application.
api.handleSubdomainApplications(
apiRateLimiter,
// Middleware to impose on the served application.
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
// The code handles the the case where the user is not
// authenticated automatically.
RedirectToLogin: false,
DisableSessionExpiryRefresh: options.DeploymentConfig.DisableSessionExpiryRefresh.Value,
Optional: true,
}),
httpmw.AsAuthzSystem(
httpmw.ExtractUserParam(api.Database, false),
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
),
),
//
// Workspace apps do their own auth.
api.handleSubdomainApplications(apiRateLimiter),
// Build-Version is helpful for debugging.
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -345,26 +348,8 @@ func New(options *Options) *API {
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) })
apps := func(r chi.Router) {
r.Use(
apiRateLimiter,
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
// 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 by the app handler.
RedirectToLogin: false,
DisableSessionExpiryRefresh: options.DeploymentConfig.DisableSessionExpiryRefresh.Value,
Optional: true,
}),
httpmw.AsAuthzSystem(
// Redirect to the login page if the user tries to open an app with
// "me" as the username and they are not logged in.
httpmw.ExtractUserParam(api.Database, true),
// Extracts the <workspace.agent> from the url
httpmw.ExtractWorkspaceAndAgentParam(api.Database),
),
)
// Workspace apps do their own auth.
r.Use(apiRateLimiter)
r.HandleFunc("/*", api.workspaceAppsProxyPath)
}
// %40 is the encoded character of the @ symbol. VS Code Web does
@ -742,9 +727,10 @@ type API struct {
WebsocketWaitGroup sync.WaitGroup
derpCloseFunc func()
metricsCache *metricscache.Cache
workspaceAgentCache *wsconncache.Cache
updateChecker *updatecheck.Checker
metricsCache *metricscache.Cache
workspaceAgentCache *wsconncache.Cache
updateChecker *updatecheck.Checker
WorkspaceAppsProvider *workspaceapps.Provider
// Experiments contains the list of experiments currently enabled.
// This is used to gate features that are not yet ready for production.

View File

@ -11,6 +11,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
@ -82,6 +83,10 @@ import (
"github.com/coder/coder/testutil"
)
// AppSigningKey is a 64-byte key used to sign JWTs for workspace app tickets in
// tests.
var AppSigningKey = must(hex.DecodeString("64656164626565666465616462656566646561646265656664656164626565666465616462656566646561646265656664656164626565666465616462656566"))
type Options struct {
// AccessURL denotes a custom access URL. By default we use the httptest
// server's URL. Setting this may result in unexpected behavior (especially
@ -330,6 +335,7 @@ func NewOptions(t *testing.T, options *Options) (func(http.Handler), context.Can
DeploymentConfig: options.DeploymentConfig,
UpdateCheckOptions: options.UpdateCheckOptions,
SwaggerEndpoint: options.SwaggerEndpoint,
AppSigningKey: AppSigningKey,
}
}

View File

@ -19,6 +19,14 @@ func (q *querier) Ping(ctx context.Context) (time.Duration, error) {
return q.db.Ping(ctx)
}
func (q *querier) AcquireLock(ctx context.Context, id int64) error {
return q.db.AcquireLock(ctx, id)
}
func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) {
return q.db.TryAcquireLock(ctx, id)
}
// InTx runs the given function in a transaction.
func (q *querier) InTx(function func(querier database.Store) error, txOpts *sql.TxOptions) error {
return q.db.InTx(func(tx database.Store) error {
@ -317,6 +325,16 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
return q.db.GetLogoURL(ctx)
}
func (q *querier) GetAppSigningKey(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetAppSigningKey(ctx)
}
func (q *querier) InsertAppSigningKey(ctx context.Context, data string) error {
// No authz checks as this is done during startup
return q.db.InsertAppSigningKey(ctx, data)
}
func (q *querier) GetServiceBanner(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetServiceBanner(ctx)

View File

@ -64,6 +64,7 @@ func New() database.Store {
workspaceApps: make([]database.WorkspaceApp, 0),
workspaces: make([]database.Workspace, 0),
licenses: make([]database.License, 0),
locks: map[int64]struct{}{},
},
}
}
@ -89,6 +90,11 @@ type fakeQuerier struct {
*data
}
type fakeTx struct {
*fakeQuerier
locks map[int64]struct{}
}
type data struct {
// Legacy tables
apiKeys []database.APIKey
@ -124,11 +130,15 @@ type data struct {
workspaceResources []database.WorkspaceResource
workspaces []database.Workspace
// Locks is a map of lock names. Any keys within the map are currently
// locked.
locks map[int64]struct{}
deploymentID string
derpMeshKey string
lastUpdateCheck []byte
serviceBanner []byte
logoURL string
appSigningKey string
lastLicenseID int32
}
@ -196,11 +206,50 @@ func (*fakeQuerier) Ping(_ context.Context) (time.Duration, error) {
return 0, nil
}
func (*fakeQuerier) AcquireLock(_ context.Context, _ int64) error {
return xerrors.New("AcquireLock must only be called within a transaction")
}
func (*fakeQuerier) TryAcquireLock(_ context.Context, _ int64) (bool, error) {
return false, xerrors.New("TryAcquireLock must only be called within a transaction")
}
func (tx *fakeTx) AcquireLock(_ context.Context, id int64) error {
if _, ok := tx.fakeQuerier.locks[id]; ok {
return xerrors.Errorf("cannot acquire lock %d: already held", id)
}
tx.fakeQuerier.locks[id] = struct{}{}
tx.locks[id] = struct{}{}
return nil
}
func (tx *fakeTx) TryAcquireLock(_ context.Context, id int64) (bool, error) {
if _, ok := tx.fakeQuerier.locks[id]; ok {
return false, nil
}
tx.fakeQuerier.locks[id] = struct{}{}
tx.locks[id] = struct{}{}
return true, nil
}
func (tx *fakeTx) releaseLocks() {
for id := range tx.locks {
delete(tx.fakeQuerier.locks, id)
}
tx.locks = map[int64]struct{}{}
}
// InTx doesn't rollback data properly for in-memory yet.
func (q *fakeQuerier) InTx(fn func(database.Store) error, _ *sql.TxOptions) error {
q.mutex.Lock()
defer q.mutex.Unlock()
return fn(&fakeQuerier{mutex: inTxMutex{}, data: q.data})
tx := &fakeTx{
fakeQuerier: &fakeQuerier{mutex: inTxMutex{}, data: q.data},
locks: map[int64]struct{}{},
}
defer tx.releaseLocks()
return fn(tx)
}
func (q *fakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.AcquireProvisionerJobParams) (database.ProvisionerJob, error) {
@ -4004,6 +4053,21 @@ func (q *fakeQuerier) GetLogoURL(_ context.Context) (string, error) {
return q.logoURL, nil
}
func (q *fakeQuerier) GetAppSigningKey(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
return q.appSigningKey, nil
}
func (q *fakeQuerier) InsertAppSigningKey(_ context.Context, data string) error {
q.mutex.Lock()
defer q.mutex.Unlock()
q.appSigningKey = data
return nil
}
func (q *fakeQuerier) InsertLicense(
_ context.Context, arg database.InsertLicenseParams,
) (database.License, error) {

8
coderd/database/lock.go Normal file
View File

@ -0,0 +1,8 @@
package database
// Well-known lock IDs for lock functions in the database. These should not
// change. If locks are deprecated, they should be kept to avoid reusing the
// same ID.
const (
LockIDDeploymentSetup = iota + 1
)

View File

@ -12,6 +12,13 @@ import (
)
type sqlcQuerier interface {
// Blocks until the lock is acquired.
//
// This must be called from within a transaction. The lock will be automatically
// released when the transaction ends.
//
// Use database.LockID() to generate a unique lock ID from a string.
AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error
// Acquires the lock for a single job that isn't started, completed,
// canceled, and that matches an array of provisioner types.
//
@ -36,6 +43,7 @@ type sqlcQuerier interface {
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
GetActiveUserCount(ctx context.Context) (int64, error)
GetAppSigningKey(ctx context.Context) (string, error)
// GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided
// ID.
GetAuditLogsOffset(ctx context.Context, arg GetAuditLogsOffsetParams) ([]GetAuditLogsOffsetRow, error)
@ -139,6 +147,7 @@ type sqlcQuerier interface {
// for simplicity since all users is
// every member of the org.
InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error)
InsertAppSigningKey(ctx context.Context, value string) error
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
InsertDERPMeshKey(ctx context.Context, value string) error
InsertDeploymentID(ctx context.Context, value string) error
@ -177,6 +186,13 @@ type sqlcQuerier interface {
InsertWorkspaceResourceMetadata(ctx context.Context, arg InsertWorkspaceResourceMetadataParams) ([]WorkspaceResourceMetadatum, error)
ParameterValue(ctx context.Context, id uuid.UUID) (ParameterValue, error)
ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error)
// Non blocking lock. Returns true if the lock was acquired, false otherwise.
//
// This must be called from within a transaction. The lock will be automatically
// released when the transaction ends.
//
// Use database.LockID() to generate a unique lock ID from a string.
TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error)
UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error
UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) (GitAuthLink, error)
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)

View File

@ -1437,6 +1437,38 @@ func (q *sqlQuerier) InsertLicense(ctx context.Context, arg InsertLicenseParams)
return i, err
}
const acquireLock = `-- name: AcquireLock :exec
SELECT pg_advisory_xact_lock($1)
`
// Blocks until the lock is acquired.
//
// This must be called from within a transaction. The lock will be automatically
// released when the transaction ends.
//
// Use database.LockID() to generate a unique lock ID from a string.
func (q *sqlQuerier) AcquireLock(ctx context.Context, pgAdvisoryXactLock int64) error {
_, err := q.db.ExecContext(ctx, acquireLock, pgAdvisoryXactLock)
return err
}
const tryAcquireLock = `-- name: TryAcquireLock :one
SELECT pg_try_advisory_xact_lock($1)
`
// Non blocking lock. Returns true if the lock was acquired, false otherwise.
//
// This must be called from within a transaction. The lock will be automatically
// released when the transaction ends.
//
// Use database.LockID() to generate a unique lock ID from a string.
func (q *sqlQuerier) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) {
row := q.db.QueryRowContext(ctx, tryAcquireLock, pgTryAdvisoryXactLock)
var pg_try_advisory_xact_lock bool
err := row.Scan(&pg_try_advisory_xact_lock)
return pg_try_advisory_xact_lock, err
}
const getOrganizationIDsByMemberIDs = `-- name: GetOrganizationIDsByMemberIDs :many
SELECT
user_id, array_agg(organization_id) :: uuid [ ] AS "organization_IDs"
@ -2909,6 +2941,17 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams)
return i, err
}
const getAppSigningKey = `-- name: GetAppSigningKey :one
SELECT value FROM site_configs WHERE key = 'app_signing_key'
`
func (q *sqlQuerier) GetAppSigningKey(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getAppSigningKey)
var value string
err := row.Scan(&value)
return value, err
}
const getDERPMeshKey = `-- name: GetDERPMeshKey :one
SELECT value FROM site_configs WHERE key = 'derp_mesh_key'
`
@ -2964,6 +3007,15 @@ func (q *sqlQuerier) GetServiceBanner(ctx context.Context) (string, error) {
return value, err
}
const insertAppSigningKey = `-- name: InsertAppSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('app_signing_key', $1)
`
func (q *sqlQuerier) InsertAppSigningKey(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, insertAppSigningKey, value)
return err
}
const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1)
`

View File

@ -0,0 +1,17 @@
-- name: AcquireLock :exec
-- Blocks until the lock is acquired.
--
-- This must be called from within a transaction. The lock will be automatically
-- released when the transaction ends.
--
-- Use database.LockID() to generate a unique lock ID from a string.
SELECT pg_advisory_xact_lock($1);
-- name: TryAcquireLock :one
-- Non blocking lock. Returns true if the lock was acquired, false otherwise.
--
-- This must be called from within a transaction. The lock will be automatically
-- released when the transaction ends.
--
-- Use database.LockID() to generate a unique lock ID from a string.
SELECT pg_try_advisory_xact_lock($1);

View File

@ -30,3 +30,9 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url';
-- name: GetLogoURL :one
SELECT value FROM site_configs WHERE key = 'logo_url';
-- name: GetAppSigningKey :one
SELECT value FROM site_configs WHERE key = 'app_signing_key';
-- name: InsertAppSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('app_signing_key', $1);

View File

@ -22,7 +22,9 @@ func StripCoderCookies(header string) string {
name, _, _ := strings.Cut(part, "=")
if name == codersdk.SessionTokenCookie ||
name == codersdk.OAuth2StateCookie ||
name == codersdk.OAuth2RedirectCookie {
name == codersdk.OAuth2RedirectCookie ||
name == codersdk.DevURLSessionTokenCookie ||
name == codersdk.DevURLSessionTicketCookie {
continue
}
cookies = append(cookies, part)

View File

@ -4,7 +4,6 @@ import (
"fmt"
"net"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
@ -23,9 +22,7 @@ var (
// ApplicationURL is a parsed application URL hostname.
type ApplicationURL struct {
// Only one of AppSlug or Port will be set.
AppSlug string
Port uint16
AppSlugOrPort string
AgentName string
WorkspaceName string
Username string
@ -34,12 +31,7 @@ type ApplicationURL struct {
// String returns the application URL hostname without scheme. You will likely
// want to append a period and the base hostname.
func (a ApplicationURL) String() string {
appSlugOrPort := a.AppSlug
if a.Port != 0 {
appSlugOrPort = strconv.Itoa(int(a.Port))
}
return fmt.Sprintf("%s--%s--%s--%s", appSlugOrPort, a.AgentName, a.WorkspaceName, a.Username)
return fmt.Sprintf("%s--%s--%s--%s", a.AppSlugOrPort, a.AgentName, a.WorkspaceName, a.Username)
}
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
@ -60,29 +52,14 @@ func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
}
matchGroup := matches[0]
appSlug, port := AppSlugOrPort(matchGroup[appURL.SubexpIndex("AppSlug")])
return ApplicationURL{
AppSlug: appSlug,
Port: port,
AppSlugOrPort: matchGroup[appURL.SubexpIndex("AppSlug")],
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
Username: matchGroup[appURL.SubexpIndex("Username")],
}, nil
}
// AppSlugOrPort takes a string and returns either the input string or a port
// number.
func AppSlugOrPort(val string) (string, uint16) {
port, err := strconv.ParseUint(val, 10, 16)
if err != nil || port == 0 {
port = 0
} else {
val = ""
}
return val, uint16(port)
}
// HostnamesMatch returns true if the hostnames are equal, disregarding
// capitalization, extra leading or trailing periods, and ports.
func HostnamesMatch(a, b string) bool {

View File

@ -25,8 +25,7 @@ func TestApplicationURLString(t *testing.T) {
{
Name: "AppName",
URL: httpapi.ApplicationURL{
AppSlug: "app",
Port: 0,
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
@ -36,26 +35,13 @@ func TestApplicationURLString(t *testing.T) {
{
Name: "Port",
URL: httpapi.ApplicationURL{
AppSlug: "",
Port: 8080,
AppSlugOrPort: "8080",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
Expected: "8080--agent--workspace--user",
},
{
Name: "Both",
URL: httpapi.ApplicationURL{
AppSlug: "app",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
},
// Prioritizes port over app name.
Expected: "8080--agent--workspace--user",
},
}
for _, c := range testCases {
@ -111,8 +97,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
Name: "AppName--Agent--Workspace--User",
Subdomain: "app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppSlug: "app",
Port: 0,
AppSlugOrPort: "app",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
@ -122,8 +107,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
Name: "Port--Agent--Workspace--User",
Subdomain: "8080--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppSlug: "",
Port: 8080,
AppSlugOrPort: "8080",
AgentName: "agent",
WorkspaceName: "workspace",
Username: "user",
@ -133,8 +117,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
Name: "HyphenatedNames",
Subdomain: "app-slug--agent-name--workspace-name--user-name",
Expected: httpapi.ApplicationURL{
AppSlug: "app-slug",
Port: 0,
AppSlugOrPort: "app-slug",
AgentName: "agent-name",
WorkspaceName: "workspace-name",
Username: "user-name",

View File

@ -25,13 +25,6 @@ import (
"github.com/coder/coder/codersdk"
)
// The special cookie name used for subdomain-based application proxying.
// TODO: this will make dogfooding harder so come up with a more unique
// solution
//
//nolint:gosec
const DevURLSessionTokenCookie = "coder_devurl_session_token"
type apiKeyContextKey struct{}
// APIKeyOptional may return an API key from the ExtractAPIKey handler.
@ -108,268 +101,281 @@ type ExtractAPIKeyConfig struct {
Optional bool
}
// ExtractAPIKey requires authentication using a valid API key. It handles
// extending an API key if it comes close to expiry, updating the last used time
// in the database.
// nolint:revive
func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
// ExtractAPIKeyMW calls ExtractAPIKey with the given config on each request,
// storing the result in the request context.
func ExtractAPIKeyMW(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Write wraps writing a response to redirect if the handler
// specified it should. This redirect is used for user-facing pages
// like workspace applications.
write := func(code int, response codersdk.Response) {
if cfg.RedirectToLogin {
RedirectToLogin(rw, r, response.Message)
return
}
httpapi.Write(ctx, rw, code, response)
}
// optionalWrite wraps write, but will pass the request on to the
// next handler if the configuration says the API key is optional.
//
// It should be used when the API key is not provided or is invalid,
// but not when there are other errors.
optionalWrite := func(code int, response codersdk.Response) {
if cfg.Optional {
next.ServeHTTP(rw, r)
return
}
write(code, response)
}
token := apiTokenFromRequest(r)
if token == "" {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie),
})
keyPtr, authzPtr, ok := ExtractAPIKey(rw, r, cfg)
if !ok {
return
}
keyID, keySecret, err := SplitAPIToken(token)
if err != nil {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "Invalid API key format: " + err.Error(),
})
return
}
//nolint:gocritic // System needs to fetch API key to check if it's valid.
key, err := cfg.DB.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "API key is invalid.",
})
return
}
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()),
})
return
}
// Checking to see if the secret is valid.
hashedSecret := sha256.Sum256([]byte(keySecret))
if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "API key secret is invalid.",
})
return
}
var (
link database.UserLink
now = database.Now()
// Tracks if the API key has properties updated
changed = false
)
if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
//nolint:gocritic // System needs to fetch UserLink to check if it's valid.
link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
UserID: key.UserID,
LoginType: key.LoginType,
})
if err != nil {
write(http.StatusInternalServerError, codersdk.Response{
Message: "A database error occurred",
Detail: fmt.Sprintf("get user link by user ID and login type: %s", err.Error()),
})
return
}
// Check if the OAuth token is expired
if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
var oauthConfig OAuth2Config
switch key.LoginType {
case database.LoginTypeGithub:
oauthConfig = cfg.OAuth2Configs.Github
case database.LoginTypeOIDC:
oauthConfig = cfg.OAuth2Configs.OIDC
default:
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Unexpected authentication type %q.", key.LoginType),
})
return
}
// If it is, let's refresh it from the provided config
token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
AccessToken: link.OAuthAccessToken,
RefreshToken: link.OAuthRefreshToken,
Expiry: link.OAuthExpiry,
}).Token()
if err != nil {
write(http.StatusUnauthorized, codersdk.Response{
Message: "Could not refresh expired Oauth token.",
Detail: err.Error(),
})
return
}
link.OAuthAccessToken = token.AccessToken
link.OAuthRefreshToken = token.RefreshToken
link.OAuthExpiry = token.Expiry
key.ExpiresAt = token.Expiry
changed = true
}
}
// Checking if the key is expired.
if key.ExpiresAt.Before(now) {
optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
})
return
}
// Only update LastUsed once an hour to prevent database spam.
if now.Sub(key.LastUsed) > time.Hour {
key.LastUsed = now
remoteIP := net.ParseIP(r.RemoteAddr)
if remoteIP == nil {
remoteIP = net.IPv4(0, 0, 0, 0)
}
bitlen := len(remoteIP) * 8
key.IPAddress = pqtype.Inet{
IPNet: net.IPNet{
IP: remoteIP,
Mask: net.CIDRMask(bitlen, bitlen),
},
Valid: true,
}
changed = true
}
// Only update the ExpiresAt once an hour to prevent database spam.
// We extend the ExpiresAt to reduce re-authentication.
if !cfg.DisableSessionExpiryRefresh {
apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
key.ExpiresAt = now.Add(apiKeyLifetime)
changed = true
}
}
if changed {
//nolint:gocritic // System needs to update API Key LastUsed
err := cfg.DB.UpdateAPIKeyByID(dbauthz.AsSystemRestricted(ctx), database.UpdateAPIKeyByIDParams{
ID: key.ID,
LastUsed: key.LastUsed,
ExpiresAt: key.ExpiresAt,
IPAddress: key.IPAddress,
})
if err != nil {
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()),
})
return
}
// If the API Key is associated with a user_link (e.g. Github/OIDC)
// then we want to update the relevant oauth fields.
if link.UserID != uuid.Nil {
// nolint:gocritic
link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
UserID: link.UserID,
LoginType: link.LoginType,
OAuthAccessToken: link.OAuthAccessToken,
OAuthRefreshToken: link.OAuthRefreshToken,
OAuthExpiry: link.OAuthExpiry,
})
if err != nil {
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
})
return
}
}
// We only want to update this occasionally to reduce DB write
// load. We update alongside the UserLink and APIKey since it's
// easier on the DB to colocate writes.
// nolint:gocritic
_, err = cfg.DB.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLastSeenAtParams{
ID: key.UserID,
LastSeenAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("update user last_seen_at: %s", err.Error()),
})
return
}
}
// If the key is valid, we also fetch the user roles and status.
// The roles are used for RBAC authorize checks, and the status
// is to block 'suspended' users from accessing the platform.
// nolint:gocritic
roles, err := cfg.DB.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), key.UserID)
if err != nil {
write(http.StatusUnauthorized, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()),
})
return
}
if roles.Status != database.UserStatusActive {
write(http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
})
if keyPtr == nil || authzPtr == nil {
// Auth was optional and not provided.
next.ServeHTTP(rw, r)
return
}
key, authz := *keyPtr, *authzPtr
// Actor is the user's authorization context.
actor := rbac.Subject{
ID: key.UserID.String(),
Roles: rbac.RoleNames(roles.Roles),
Groups: roles.Groups,
Scope: rbac.ScopeName(key.Scope),
}
ctx := r.Context()
ctx = context.WithValue(ctx, apiKeyContextKey{}, key)
ctx = context.WithValue(ctx, userAuthKey{}, Authorization{
Username: roles.Username,
Actor: actor,
})
ctx = context.WithValue(ctx, userAuthKey{}, authz)
// Set the auth context for the authzquerier as well.
ctx = dbauthz.As(ctx, actor)
ctx = dbauthz.As(ctx, authz.Actor)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
// ExtractAPIKey requires authentication using a valid API key. It handles
// extending an API key if it comes close to expiry, updating the last used time
// in the database.
//
// If the configuration specifies that the API key is optional, a nil API key
// and authz object may be returned. False is returned if a response was written
// to the request and the caller should give up.
// nolint:revive
func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyConfig) (*database.APIKey, *Authorization, bool) {
ctx := r.Context()
// Write wraps writing a response to redirect if the handler
// specified it should. This redirect is used for user-facing pages
// like workspace applications.
write := func(code int, response codersdk.Response) (*database.APIKey, *Authorization, bool) {
if cfg.RedirectToLogin {
RedirectToLogin(rw, r, response.Message)
return nil, nil, false
}
httpapi.Write(ctx, rw, code, response)
return nil, nil, false
}
// optionalWrite wraps write, but will return nil, true if the API key is
// optional.
//
// It should be used when the API key is not provided or is invalid,
// but not when there are other errors.
optionalWrite := func(code int, response codersdk.Response) (*database.APIKey, *Authorization, bool) {
if cfg.Optional {
return nil, nil, true
}
write(code, response)
return nil, nil, false
}
token := apiTokenFromRequest(r)
if token == "" {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: fmt.Sprintf("Cookie %q or query parameter must be provided.", codersdk.SessionTokenCookie),
})
}
keyID, keySecret, err := SplitAPIToken(token)
if err != nil {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "Invalid API key format: " + err.Error(),
})
}
//nolint:gocritic // System needs to fetch API key to check if it's valid.
key, err := cfg.DB.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), keyID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "API key is invalid.",
})
}
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Internal error fetching API key by id. %s", err.Error()),
})
}
// Checking to see if the secret is valid.
hashedSecret := sha256.Sum256([]byte(keySecret))
if subtle.ConstantTimeCompare(key.HashedSecret, hashedSecret[:]) != 1 {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: "API key secret is invalid.",
})
}
var (
link database.UserLink
now = database.Now()
// Tracks if the API key has properties updated
changed = false
)
if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
//nolint:gocritic // System needs to fetch UserLink to check if it's valid.
link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
UserID: key.UserID,
LoginType: key.LoginType,
})
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
Message: "A database error occurred",
Detail: fmt.Sprintf("get user link by user ID and login type: %s", err.Error()),
})
}
// Check if the OAuth token is expired
if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
var oauthConfig OAuth2Config
switch key.LoginType {
case database.LoginTypeGithub:
oauthConfig = cfg.OAuth2Configs.Github
case database.LoginTypeOIDC:
oauthConfig = cfg.OAuth2Configs.OIDC
default:
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Unexpected authentication type %q.", key.LoginType),
})
}
// If it is, let's refresh it from the provided config
token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
AccessToken: link.OAuthAccessToken,
RefreshToken: link.OAuthRefreshToken,
Expiry: link.OAuthExpiry,
}).Token()
if err != nil {
return write(http.StatusUnauthorized, codersdk.Response{
Message: "Could not refresh expired Oauth token.",
Detail: err.Error(),
})
}
link.OAuthAccessToken = token.AccessToken
link.OAuthRefreshToken = token.RefreshToken
link.OAuthExpiry = token.Expiry
key.ExpiresAt = token.Expiry
changed = true
}
}
// Checking if the key is expired.
if key.ExpiresAt.Before(now) {
return optionalWrite(http.StatusUnauthorized, codersdk.Response{
Message: SignedOutErrorMessage,
Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
})
}
// Only update LastUsed once an hour to prevent database spam.
if now.Sub(key.LastUsed) > time.Hour {
key.LastUsed = now
remoteIP := net.ParseIP(r.RemoteAddr)
if remoteIP == nil {
remoteIP = net.IPv4(0, 0, 0, 0)
}
bitlen := len(remoteIP) * 8
key.IPAddress = pqtype.Inet{
IPNet: net.IPNet{
IP: remoteIP,
Mask: net.CIDRMask(bitlen, bitlen),
},
Valid: true,
}
changed = true
}
// Only update the ExpiresAt once an hour to prevent database spam.
// We extend the ExpiresAt to reduce re-authentication.
if !cfg.DisableSessionExpiryRefresh {
apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
key.ExpiresAt = now.Add(apiKeyLifetime)
changed = true
}
}
if changed {
//nolint:gocritic // System needs to update API Key LastUsed
err := cfg.DB.UpdateAPIKeyByID(dbauthz.AsSystemRestricted(ctx), database.UpdateAPIKeyByIDParams{
ID: key.ID,
LastUsed: key.LastUsed,
ExpiresAt: key.ExpiresAt,
IPAddress: key.IPAddress,
})
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()),
})
}
// If the API Key is associated with a user_link (e.g. Github/OIDC)
// then we want to update the relevant oauth fields.
if link.UserID != uuid.Nil {
// nolint:gocritic
link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
UserID: link.UserID,
LoginType: link.LoginType,
OAuthAccessToken: link.OAuthAccessToken,
OAuthRefreshToken: link.OAuthRefreshToken,
OAuthExpiry: link.OAuthExpiry,
})
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
})
}
}
// We only want to update this occasionally to reduce DB write
// load. We update alongside the UserLink and APIKey since it's
// easier on the DB to colocate writes.
// nolint:gocritic
_, err = cfg.DB.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLastSeenAtParams{
ID: key.UserID,
LastSeenAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("update user last_seen_at: %s", err.Error()),
})
}
}
// If the key is valid, we also fetch the user roles and status.
// The roles are used for RBAC authorize checks, and the status
// is to block 'suspended' users from accessing the platform.
// nolint:gocritic
roles, err := cfg.DB.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), key.UserID)
if err != nil {
return write(http.StatusUnauthorized, codersdk.Response{
Message: internalErrorMessage,
Detail: fmt.Sprintf("Internal error fetching user's roles. %s", err.Error()),
})
}
if roles.Status != database.UserStatusActive {
return write(http.StatusUnauthorized, codersdk.Response{
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
})
}
// Actor is the user's authorization context.
authz := Authorization{
Username: roles.Username,
Actor: rbac.Subject{
ID: key.UserID.String(),
Roles: rbac.RoleNames(roles.Roles),
Groups: roles.Groups,
Scope: rbac.ScopeName(key.Scope),
},
}
return &key, &authz, true
}
// apiTokenFromRequest returns the api token from the request.
// Find the session token from:
// 1: The cookie
@ -393,7 +399,7 @@ func apiTokenFromRequest(r *http.Request) string {
return headerValue
}
cookie, err = r.Cookie(DevURLSessionTokenCookie)
cookie, err = r.Cookie(codersdk.DevURLSessionTokenCookie)
if err == nil && cookie.Value != "" {
return cookie.Value
}

View File

@ -47,7 +47,7 @@ func TestAPIKey(t *testing.T) {
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -63,7 +63,7 @@ func TestAPIKey(t *testing.T) {
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: true,
})(successHandler).ServeHTTP(rw, r)
@ -84,7 +84,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, "test-wow-hello")
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -102,7 +102,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, "test-wow")
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -120,7 +120,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, "testtestid-wow")
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -139,7 +139,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, fmt.Sprintf("%s-%s", id, secret))
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -164,7 +164,7 @@ func TestAPIKey(t *testing.T) {
})
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -188,7 +188,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -212,7 +212,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@ -251,7 +251,7 @@ func TestAPIKey(t *testing.T) {
Value: token,
})
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@ -286,7 +286,7 @@ func TestAPIKey(t *testing.T) {
q.Add(codersdk.SessionTokenCookie, token)
r.URL.RawQuery = q.Encode()
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@ -317,7 +317,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -348,7 +348,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -379,7 +379,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
DisableSessionExpiryRefresh: true,
@ -416,7 +416,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -459,7 +459,7 @@ func TestAPIKey(t *testing.T) {
RefreshToken: "moo",
Expiry: database.Now().AddDate(0, 0, 1),
}
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
OAuth2Configs: &httpmw.OAuth2Configs{
Github: &oauth2Config{
@ -498,7 +498,7 @@ func TestAPIKey(t *testing.T) {
r.RemoteAddr = "1.1.1.1"
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)
@ -520,7 +520,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: true,
})(successHandler).ServeHTTP(rw, r)
@ -552,7 +552,7 @@ func TestAPIKey(t *testing.T) {
})
)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
Optional: true,
@ -581,7 +581,7 @@ func TestAPIKey(t *testing.T) {
)
r.Header.Set(codersdk.SessionTokenHeader, token)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(successHandler).ServeHTTP(rw, r)

View File

@ -118,7 +118,7 @@ func TestExtractUserRoles(t *testing.T) {
rtr = chi.NewRouter()
)
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
OAuth2Configs: &httpmw.OAuth2Configs{},
RedirectToLogin: false,

View File

@ -43,7 +43,7 @@ func TestOrganizationParam(t *testing.T) {
rtr = chi.NewRouter()
)
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),
@ -66,7 +66,7 @@ func TestOrganizationParam(t *testing.T) {
)
chi.RouteContext(r.Context()).URLParams.Add("organization", uuid.NewString())
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),
@ -89,7 +89,7 @@ func TestOrganizationParam(t *testing.T) {
)
chi.RouteContext(r.Context()).URLParams.Add("organization", "not-a-uuid")
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),
@ -120,7 +120,7 @@ func TestOrganizationParam(t *testing.T) {
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String())
chi.RouteContext(r.Context()).URLParams.Add("user", u.ID.String())
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),
@ -151,7 +151,7 @@ func TestOrganizationParam(t *testing.T) {
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.ID.String())
chi.RouteContext(r.Context()).URLParams.Add("user", user.ID.String())
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),

View File

@ -76,7 +76,7 @@ func TestRateLimit(t *testing.T) {
_, key := dbgen.APIKey(t, db, database.APIKey{UserID: u.ID})
rtr := chi.NewRouter()
rtr.Use(httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
rtr.Use(httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
Optional: false,
}))
@ -122,7 +122,7 @@ func TestRateLimit(t *testing.T) {
_, key := dbgen.APIKey(t, db, database.APIKey{UserID: u.ID})
rtr := chi.NewRouter()
rtr.Use(httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
rtr.Use(httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
Optional: false,
}))

View File

@ -95,7 +95,7 @@ func TestTemplateParam(t *testing.T) {
db := dbfake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),

View File

@ -82,7 +82,7 @@ func TestTemplateVersionParam(t *testing.T) {
db := dbfake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),

View File

@ -37,7 +37,7 @@ func TestUserParam(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
@ -56,7 +56,7 @@ func TestUserParam(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
@ -78,7 +78,7 @@ func TestUserParam(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
})(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {

View File

@ -96,7 +96,7 @@ func TestWorkspaceAgentParam(t *testing.T) {
db := dbfake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),

View File

@ -78,7 +78,7 @@ func TestWorkspaceBuildParam(t *testing.T) {
db := dbfake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),

View File

@ -101,7 +101,7 @@ func TestWorkspaceParam(t *testing.T) {
db := dbfake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: false,
}),
@ -302,7 +302,7 @@ func TestWorkspaceAgentByNameParam(t *testing.T) {
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: db,
RedirectToLogin: true,
}),

View File

@ -704,6 +704,8 @@ func TestDeleteTemplate(t *testing.T) {
func TestTemplateMetrics(t *testing.T) {
t.Parallel()
t.Skip("flaky test: https://github.com/coder/coder/issues/6481")
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,

View File

@ -197,11 +197,11 @@ func (api *API) postLogout(rw http.ResponseWriter, r *http.Request) {
// Deployments should not host app tokens on the same domain as the
// primary deployment. But in the case they are, we should also delete this
// token.
if appCookie, _ := r.Cookie(httpmw.DevURLSessionTokenCookie); appCookie != nil {
if appCookie, _ := r.Cookie(codersdk.DevURLSessionTokenCookie); appCookie != nil {
appCookieRemove := &http.Cookie{
// MaxAge < 0 means to delete the cookie now.
MaxAge: -1,
Name: httpmw.DevURLSessionTokenCookie,
Name: codersdk.DevURLSessionTokenCookie,
Path: "/",
Domain: "." + api.AccessURL.Hostname(),
}

View File

@ -17,7 +17,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
jose "gopkg.in/square/go-jose.v2"
@ -29,6 +28,7 @@ import (
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/workspaceapps"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/site"
)
@ -38,9 +38,6 @@ const (
// conflict with query parameters that users may use.
//nolint:gosec
subdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
// 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"
// appLogoutHostname is the hostname to use for the logout redirect. When
// the dashboard logs out, it will redirect to this subdomain of the app
// hostname, and the server will remove the cookie and redirect to the main
@ -66,13 +63,6 @@ var nonCanonicalHeaders = map[string]string{
"Sec-Websocket-Version": "Sec-WebSocket-Version",
}
type workspaceAppAccessMethod string
const (
workspaceAppAccessMethodPath workspaceAppAccessMethod = "path"
workspaceAppAccessMethodSubdomain workspaceAppAccessMethod = "subdomain"
)
// @Summary Get applications host
// @ID get-applications-host
// @Security CoderSessionToken
@ -94,9 +84,6 @@ func (api *API) appHost(rw http.ResponseWriter, r *http.Request) {
// workspaceAppsProxyPath proxies requests to a workspace application
// through a relative URL path.
func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
if api.DeploymentConfig.DisablePathApps.Value {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusUnauthorized,
@ -108,32 +95,24 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
return
}
// We do not support port proxying on paths, so lookup the app by slug.
appSlug := chi.URLParam(r, "workspaceapp")
app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appSlug)
if !ok {
return
}
appSharingLevel := database.AppSharingLevelOwner
if app.SharingLevel != "" {
appSharingLevel = app.SharingLevel
}
authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspaceAppAccessMethodPath, workspace, appSharingLevel)
if !ok {
return
}
if !authed {
_, hasAPIKey := httpmw.APIKeyOptional(r)
if hasAPIKey {
// The request has a valid API key but insufficient permissions.
renderApplicationNotFound(rw, r, api.AccessURL)
// If the username in the request is @me, then redirect to the current
// username. The resolveWorkspaceApp function does not accept @me for
// security purposes.
if chi.URLParam(r, "user") == codersdk.Me {
_, roles, ok := httpmw.ExtractAPIKey(rw, r, httpmw.ExtractAPIKeyConfig{
DB: api.Database,
OAuth2Configs: &httpmw.OAuth2Configs{
Github: api.GithubOAuth2Config,
OIDC: api.OIDCConfig,
},
RedirectToLogin: true,
DisableSessionExpiryRefresh: api.DeploymentConfig.DisableSessionExpiryRefresh.Value,
})
if !ok {
return
}
// Redirect to login as they don't have permission to access the app and
// they aren't signed in.
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
http.Redirect(rw, r, strings.Replace(r.URL.Path, "@me", "@"+roles.Username, 1), http.StatusTemporaryRedirect)
return
}
@ -145,14 +124,20 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
chiPath = "/" + chiPath
}
api.proxyWorkspaceApplication(proxyApplication{
AccessMethod: workspaceAppAccessMethodPath,
Workspace: workspace,
Agent: agent,
App: &app,
Port: 0,
Path: chiPath,
}, rw, r)
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: basePath,
UsernameOrID: chi.URLParam(r, "user"),
WorkspaceAndAgent: chi.URLParam(r, "workspace_and_agent"),
// We don't support port proxying on paths. The ResolveRequest method
// won't allow port proxying on path-based apps if the app is a number.
AppSlugOrPort: chi.URLParam(r, "workspaceapp"),
})
if !ok {
return
}
api.proxyWorkspaceApplication(rw, r, *ticket, chiPath)
}
// handleSubdomainApplications handles subdomain-based application proxy
@ -226,451 +211,64 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
return
}
workspaceAgentKey := fmt.Sprintf("%s.%s", app.WorkspaceName, app.AgentName)
chiCtx := chi.RouteContext(ctx)
chiCtx.URLParams.Add("workspace_and_agent", workspaceAgentKey)
chiCtx.URLParams.Add("user", app.Username)
// Use the passed in app middlewares before passing to the proxy app.
mws := chi.Middlewares(middlewares)
mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
var workspaceAppPtr *database.WorkspaceApp
if app.AppSlug != "" {
workspaceApp, ok := api.lookupWorkspaceApp(rw, r, agent.ID, app.AppSlug)
if !ok {
return
}
workspaceAppPtr = &workspaceApp
}
// Verify application auth. This function will redirect or
// return an error page if the user doesn't have permission.
sharingLevel := database.AppSharingLevelOwner
if workspaceAppPtr != nil && workspaceAppPtr.SharingLevel != "" {
sharingLevel = workspaceAppPtr.SharingLevel
}
if !api.verifyWorkspaceApplicationSubdomainAuth(rw, r, host, workspace, sharingLevel) {
// If the request has the special query param then we need to set a
// cookie and strip that query parameter.
if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" {
// Exchange the encoded API key for a real one.
_, token, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
// Retry is disabled because the user needs to remove
// the query parameter before they try again.
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return
}
api.proxyWorkspaceApplication(proxyApplication{
AccessMethod: workspaceAppAccessMethodSubdomain,
Workspace: workspace,
Agent: agent,
App: workspaceAppPtr,
Port: app.Port,
Path: r.URL.Path,
}, rw, r)
api.setWorkspaceAppCookie(rw, r, token)
// Strip the query parameter.
path := r.URL.Path
if path == "" {
path = "/"
}
q := r.URL.Query()
q.Del(subdomainProxyAPIKeyParam)
rawQuery := q.Encode()
if rawQuery != "" {
path += "?" + q.Encode()
}
http.Redirect(rw, r, path, http.StatusTemporaryRedirect)
return
}
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodSubdomain,
BasePath: "/",
UsernameOrID: app.Username,
WorkspaceNameOrID: app.WorkspaceName,
AgentNameOrID: app.AgentName,
AppSlugOrPort: app.AppSlugOrPort,
})
if !ok {
return
}
// Use the passed in app middlewares before passing to the proxy
// app.
mws := chi.Middlewares(middlewares)
mws.Handler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
api.proxyWorkspaceApplication(rw, r, *ticket, r.URL.Path)
})).ServeHTTP(rw, r.WithContext(ctx))
})
}
}
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
// Check if the hostname matches the access URL. If it does, the user was
// definitely trying to connect to the dashboard/API.
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// If there are no periods in the hostname, then it can't be a valid
// application URL.
if !strings.Contains(host, ".") {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host)
if !ok {
// Doesn't match the regex, so it's not a valid application URL.
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// Check if the request is part of a logout flow.
if subdomain == appLogoutHostname {
api.handleWorkspaceAppLogout(rw, r)
return httpapi.ApplicationURL{}, false
}
// Parse the application URL from the subdomain.
app, err := httpapi.ParseSubdomainAppURL(subdomain)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid application URL",
Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()),
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return httpapi.ApplicationURL{}, false
}
return app, true
}
func (api *API) handleWorkspaceAppLogout(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Delete the API key and cookie first before attempting to parse/validate
// the redirect URI.
cookie, err := r.Cookie(httpmw.DevURLSessionTokenCookie)
if err == nil && cookie.Value != "" {
id, secret, err := httpmw.SplitAPIToken(cookie.Value)
// If it's not a valid token then we don't need to delete it from the
// database, but we'll still delete the cookie.
if err == nil {
// To avoid a situation where someone overloads the API with
// different auth formats, and tricks this endpoint into deleting an
// unchecked API key, we validate that the secret matches the secret
// we store in the database.
//nolint:gocritic // needed for workspace app logout
apiKey, err := api.Database.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), id)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to lookup API key.",
Detail: err.Error(),
})
return
}
// This is wrapped in `err == nil` because if the API key doesn't
// exist, we still want to delete the cookie.
if err == nil {
hashedSecret := sha256.Sum256([]byte(secret))
if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: httpmw.SignedOutErrorMessage,
Detail: "API key secret is invalid.",
})
return
}
//nolint:gocritic // needed for workspace app logout
err = api.Database.DeleteAPIKeyByID(dbauthz.AsSystemRestricted(ctx), id)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete API key.",
Detail: err.Error(),
})
return
}
}
}
}
if !api.setWorkspaceAppCookie(rw, r, "") {
return
}
// Read the redirect URI from the query string.
redirectURI := r.URL.Query().Get(redirectURIQueryParam)
if redirectURI == "" {
redirectURI = api.AccessURL.String()
} else {
// Validate that the redirect URI is a valid URL and exists on the same
// host as the access URL or an app URL.
parsedRedirectURI, err := url.Parse(redirectURI)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid redirect URI",
Description: fmt.Sprintf("Could not parse redirect URI %q: %s", redirectURI, err.Error()),
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return
}
// Check if the redirect URI is on the same host as the access URL or an
// app URL.
ok := httpapi.HostnamesMatch(api.AccessURL.Hostname(), parsedRedirectURI.Hostname())
if !ok && api.AppHostnameRegex != nil {
// We could also check that it's a valid application URL for
// completeness, but this check should be good enough.
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, parsedRedirectURI.Hostname())
}
if !ok {
// The redirect URI they provided is not allowed, but we don't want
// to return an error page because it'll interrupt the logout flow,
// so we just use the default access URL.
parsedRedirectURI = api.AccessURL
}
redirectURI = parsedRedirectURI.String()
}
http.Redirect(rw, r, redirectURI, http.StatusTemporaryRedirect)
}
// 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 (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appSlug string) (database.WorkspaceApp, bool) {
// dbauthz.AsSystemRestricted is allowed here as the app authz is checked later.
// The app authz is determined by the sharing level.
//nolint:gocritic
app, err := api.Database.GetWorkspaceAppByAgentIDAndSlug(dbauthz.AsSystemRestricted(r.Context()), database.GetWorkspaceAppByAgentIDAndSlugParams{
AgentID: agentID,
Slug: appSlug,
})
if xerrors.Is(err, sql.ErrNoRows) {
renderApplicationNotFound(rw, r, api.AccessURL)
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: api.AccessURL.String(),
})
return database.WorkspaceApp{}, false
}
return app, true
}
//nolint:revive
func (api *API) authorizeWorkspaceApp(r *http.Request, accessMethod workspaceAppAccessMethod, sharingLevel database.AppSharingLevel, workspace database.Workspace) (bool, error) {
ctx := r.Context()
if accessMethod == "" {
accessMethod = workspaceAppAccessMethodPath
}
isPathApp := accessMethod == workspaceAppAccessMethodPath
// 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 && !api.DeploymentConfig.Dangerous.AllowPathAppSharing.Value {
sharingLevel = database.AppSharingLevelOwner
}
// Short circuit if not authenticated.
roles, ok := httpmw.UserAuthorizationOptional(r)
if !ok {
// 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 &&
!api.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 := api.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 := api.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.AppAuthorizer
// 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 (api *API) fetchWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, accessMethod workspaceAppAccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) (authed bool, ok bool) {
ok, err := api.authorizeWorkspaceApp(r, accessMethod, appSharingLevel, workspace)
if err != nil {
api.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: api.AccessURL.String(),
})
return false, false
}
return ok, true
}
// checkWorkspaceApplicationAuth authorizes the user using api.AppAuthorizer
// for a given app share level in the given workspace. If the user is not
// authorized or a server error occurs, a discrete HTML error page is rendered
// and false is returned so the caller can return early.
func (api *API) checkWorkspaceApplicationAuth(rw http.ResponseWriter, r *http.Request, accessMethod workspaceAppAccessMethod, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool {
authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, accessMethod, workspace, appSharingLevel)
if !ok {
return false
}
if !authed {
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
return true
}
// verifyWorkspaceApplicationSubdomainAuth checks that the request is authorized
// to access the given application. If the user does not have a app session key,
// they will be redirected to the route below. If the user does have a session
// key but insufficient permissions a static error page will be rendered.
func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter, r *http.Request, host string, workspace database.Workspace, appSharingLevel database.AppSharingLevel) bool {
authed, ok := api.fetchWorkspaceApplicationAuth(rw, r, workspaceAppAccessMethodSubdomain, workspace, appSharingLevel)
if !ok {
return false
}
if authed {
return true
}
_, hasAPIKey := httpmw.APIKeyOptional(r)
if hasAPIKey {
// The request has a valid API key but insufficient permissions.
renderApplicationNotFound(rw, r, api.AccessURL)
return false
}
// If the request has the special query param then we need to set a cookie
// and strip that query parameter.
if encryptedAPIKey := r.URL.Query().Get(subdomainProxyAPIKeyParam); encryptedAPIKey != "" {
// Exchange the encoded API key for a real one.
_, token, err := decryptAPIKey(r.Context(), api.Database, encryptedAPIKey)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: "Could not decrypt API key. Please remove the query parameter and try again.",
// Retry is disabled because the user needs to remove the query
// parameter before they try again.
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return false
}
api.setWorkspaceAppCookie(rw, r, token)
// Strip the query parameter.
path := r.URL.Path
if path == "" {
path = "/"
}
q := r.URL.Query()
q.Del(subdomainProxyAPIKeyParam)
rawQuery := q.Encode()
if rawQuery != "" {
path += "?" + q.Encode()
}
http.Redirect(rw, r, path, http.StatusTemporaryRedirect)
return false
}
// If the user doesn't have a session key, redirect them to the API endpoint
// for application auth.
redirectURI := *r.URL
redirectURI.Scheme = api.AccessURL.Scheme
redirectURI.Host = host
u := *api.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)
return false
}
// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
// hostname cannot be parsed properly, a static error page is rendered and false
// is returned.
//
// If an empty token is supplied, it will clear the cookie.
func (api *API) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
hostSplit := strings.SplitN(api.AppHostname, ".", 2)
if len(hostSplit) != 2 {
// This should be impossible as we verify the app hostname on
// startup, but we'll check anyways.
api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname))
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return false
}
// Set the app cookie for all subdomains of api.AppHostname. This cookie is
// handled properly by the ExtractAPIKey middleware.
//
// We don't set an expiration because the key in the database already has an
// expiration.
maxAge := 0
if token == "" {
maxAge = -1
}
cookieHost := "." + hostSplit[1]
http.SetCookie(rw, &http.Cookie{
Name: httpmw.DevURLSessionTokenCookie,
Value: token,
Domain: cookieHost,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: api.SecureAuthCookie,
})
return true
}
// workspaceApplicationAuth is an endpoint on the main router that handles
// redirects from the subdomain handler.
//
@ -701,7 +299,7 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
}
// Get the redirect URI from the query parameters and parse it.
redirectURI := r.URL.Query().Get(redirectURIQueryParam)
redirectURI := r.URL.Query().Get(workspaceapps.RedirectURIQueryParam)
if redirectURI == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Missing redirect_uri query parameter.",
@ -781,35 +379,192 @@ func (api *API) workspaceApplicationAuth(rw http.ResponseWriter, r *http.Request
http.Redirect(rw, r, u.String(), http.StatusTemporaryRedirect)
}
// proxyApplication are the required fields to proxy a workspace application.
type proxyApplication struct {
AccessMethod workspaceAppAccessMethod
Workspace database.Workspace
Agent database.WorkspaceAgent
func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
// Check if the hostname matches the access URL. If it does, the user was
// definitely trying to connect to the dashboard/API.
if httpapi.HostnamesMatch(api.AccessURL.Hostname(), host) {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// Either App or Port must be set, but not both.
App *database.WorkspaceApp
Port uint16
// If there are no periods in the hostname, then it can't be a valid
// application URL.
if !strings.Contains(host, ".") {
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// SharingLevel MUST be set to database.AppSharingLevelOwner by default for
// ports.
SharingLevel database.AppSharingLevel
// Path must either be empty or have a leading slash.
Path string
// Split the subdomain so we can parse the application details and verify it
// matches the configured app hostname later.
subdomain, ok := httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, host)
if !ok {
// Doesn't match the regex, so it's not a valid application URL.
next.ServeHTTP(rw, r)
return httpapi.ApplicationURL{}, false
}
// Check if the request is part of a logout flow.
if subdomain == appLogoutHostname {
api.handleWorkspaceSubdomainAppLogout(rw, r)
return httpapi.ApplicationURL{}, false
}
// Parse the application URL from the subdomain.
app, err := httpapi.ParseSubdomainAppURL(subdomain)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid application URL",
Description: fmt.Sprintf("Could not parse subdomain application URL %q: %s", subdomain, err.Error()),
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return httpapi.ApplicationURL{}, false
}
return app, true
}
func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.ResponseWriter, r *http.Request) {
func (api *API) handleWorkspaceSubdomainAppLogout(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sharingLevel := database.AppSharingLevelOwner
if proxyApp.App != nil && proxyApp.App.SharingLevel != "" {
sharingLevel = proxyApp.App.SharingLevel
// Delete the API key and cookie first before attempting to parse/validate
// the redirect URI.
cookie, err := r.Cookie(codersdk.DevURLSessionTokenCookie)
if err == nil && cookie.Value != "" {
id, secret, err := httpmw.SplitAPIToken(cookie.Value)
// If it's not a valid token then we don't need to delete it from the
// database, but we'll still delete the cookie.
if err == nil {
// To avoid a situation where someone overloads the API with
// different auth formats, and tricks this endpoint into deleting an
// unchecked API key, we validate that the secret matches the secret
// we store in the database.
//nolint:gocritic // needed for workspace app logout
apiKey, err := api.Database.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), id)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to lookup API key.",
Detail: err.Error(),
})
return
}
// This is wrapped in `err == nil` because if the API key doesn't
// exist, we still want to delete the cookie.
if err == nil {
hashedSecret := sha256.Sum256([]byte(secret))
if subtle.ConstantTimeCompare(apiKey.HashedSecret, hashedSecret[:]) != 1 {
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
Message: httpmw.SignedOutErrorMessage,
Detail: "API key secret is invalid.",
})
return
}
//nolint:gocritic // needed for workspace app logout
err = api.Database.DeleteAPIKeyByID(dbauthz.AsSystemRestricted(ctx), id)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete API key.",
Detail: err.Error(),
})
return
}
}
}
}
if !api.checkWorkspaceApplicationAuth(rw, r, proxyApp.AccessMethod, proxyApp.Workspace, sharingLevel) {
if !api.setWorkspaceAppCookie(rw, r, "") {
return
}
// Filter IP headers from untrusted origins!
// Read the redirect URI from the query string.
redirectURI := r.URL.Query().Get(workspaceapps.RedirectURIQueryParam)
if redirectURI == "" {
redirectURI = api.AccessURL.String()
} else {
// Validate that the redirect URI is a valid URL and exists on the same
// host as the access URL or an app URL.
parsedRedirectURI, err := url.Parse(redirectURI)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Invalid redirect URI",
Description: fmt.Sprintf("Could not parse redirect URI %q: %s", redirectURI, err.Error()),
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return
}
// Check if the redirect URI is on the same host as the access URL or an
// app URL.
ok := httpapi.HostnamesMatch(api.AccessURL.Hostname(), parsedRedirectURI.Hostname())
if !ok && api.AppHostnameRegex != nil {
// We could also check that it's a valid application URL for
// completeness, but this check should be good enough.
_, ok = httpapi.ExecuteHostnamePattern(api.AppHostnameRegex, parsedRedirectURI.Hostname())
}
if !ok {
// The redirect URI they provided is not allowed, but we don't want
// to return an error page because it'll interrupt the logout flow,
// so we just use the default access URL.
parsedRedirectURI = api.AccessURL
}
redirectURI = parsedRedirectURI.String()
}
http.Redirect(rw, r, redirectURI, http.StatusTemporaryRedirect)
}
// setWorkspaceAppCookie sets a cookie on the workspace app domain. If the app
// hostname cannot be parsed properly, a static error page is rendered and false
// is returned.
//
// If an empty token is supplied, it will clear the cookie.
func (api *API) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
hostSplit := strings.SplitN(api.AppHostname, ".", 2)
if len(hostSplit) != 2 {
// This should be impossible as we verify the app hostname on
// startup, but we'll check anyways.
api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname))
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return false
}
// Set the app cookie for all subdomains of api.AppHostname. This cookie is
// handled properly by the ExtractAPIKey middleware.
//
// We don't set an expiration because the key in the database already has an
// expiration.
maxAge := 0
if token == "" {
maxAge = -1
}
cookieHost := "." + hostSplit[1]
http.SetCookie(rw, &http.Cookie{
Name: codersdk.DevURLSessionTokenCookie,
Value: token,
Domain: cookieHost,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: api.SecureAuthCookie,
})
return true
}
func (api *API) proxyWorkspaceApplication(rw http.ResponseWriter, r *http.Request, ticket workspaceapps.Ticket, path string) {
ctx := r.Context()
// Filter IP headers from untrusted origins.
httpmw.FilterUntrustedOriginHeaders(api.RealIPConfig, r)
// Ensure proper IP headers get sent to the forwarded application.
err := httpmw.EnsureXForwardedForHeader(r)
@ -818,32 +573,12 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
return
}
// If the app does not exist, but 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.
internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port)
if proxyApp.App != nil {
if !proxyApp.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.", proxyApp.App.Slug),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
return
}
internalURL = proxyApp.App.Url.String
}
appURL, err := url.Parse(internalURL)
appURL, err := url.Parse(ticket.AppURL)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application has an invalid URL %q: %s", internalURL, err.Error()),
Description: fmt.Sprintf("Application has an invalid URL %q: %s", ticket.AppURL, err.Error()),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})
@ -857,7 +592,7 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
portInt, err := strconv.Atoi(port)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("App URL %q has an invalid port %q.", internalURL, port),
Message: fmt.Sprintf("App URL %q has an invalid port %q.", ticket.AppURL, port),
Detail: err.Error(),
})
return
@ -872,14 +607,14 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
}
// Ensure path and query parameter correctness.
if proxyApp.Path == "" {
if path == "" {
// Web applications typically request paths relative to the
// root URL. This allows for routing behind a proxy or subpath.
// See https://github.com/coder/code-server/issues/241 for examples.
http.Redirect(rw, r, r.URL.Path+"/", http.StatusTemporaryRedirect)
return
}
if proxyApp.Path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "" {
if path == "/" && r.URL.RawQuery == "" && appURL.RawQuery != "" {
// If the application defines a default set of query parameters,
// we should always respect them. The reverse proxy will merge
// query parameters for server-side requests, but sometimes
@ -890,7 +625,7 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
return
}
r.URL.Path = proxyApp.Path
r.URL.Path = path
appURL.RawQuery = ""
proxy := httputil.NewSingleHostReverseProxy(appURL)
@ -904,7 +639,7 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
})
}
conn, release, err := api.workspaceAgentCache.Acquire(r, proxyApp.Agent.ID)
conn, release, err := api.workspaceAgentCache.Acquire(r, ticket.AgentID)
if err != nil {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadGateway,
@ -1060,15 +795,3 @@ func decryptAPIKey(ctx context.Context, db database.Store, encryptedAPIKey strin
return key, payload.APIKey, nil
}
// renderApplicationNotFound should always be used when the app is not found or
// the current user doesn't have permission to access it.
func renderApplicationNotFound(rw http.ResponseWriter, r *http.Request, accessURL *url.URL) {
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(),
})
}

View 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(),
})
}

View 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)
})
}

View 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,
}
}

View 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
}

View 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)
}
})
}
}

View 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
}

View 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)
})
}

View File

@ -8,8 +8,10 @@ import (
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"testing"
"time"
@ -129,9 +131,27 @@ func setupProxyTest(t *testing.T, opts *setupProxyTestOpts) (*codersdk.Client, c
opts.AppHost = proxyTestSubdomainRaw
}
// #nosec
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
// Start a listener on a random port greater than the minimum app port.
var (
ln net.Listener
tcpAddr *net.TCPAddr
)
for i := 0; i < 10; i++ {
var err error
// #nosec
ln, err = net.Listen("tcp", ":0")
require.NoError(t, err)
var ok bool
tcpAddr, ok = ln.Addr().(*net.TCPAddr)
require.True(t, ok)
if tcpAddr.Port < codersdk.WorkspaceAgentMinimumListeningPort {
_ = ln.Close()
time.Sleep(20 * time.Millisecond)
continue
}
}
server := http.Server{
ReadHeaderTimeout: time.Minute,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -147,8 +167,6 @@ func setupProxyTest(t *testing.T, opts *setupProxyTestOpts) (*codersdk.Client, c
_ = ln.Close()
})
go server.Serve(ln)
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
require.True(t, ok)
deploymentConfig := coderdtest.DeploymentConfig(t)
deploymentConfig.DisablePathApps.Value = opts.DisablePathApps
@ -304,7 +322,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
@ -323,7 +341,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
@ -344,7 +362,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
resp, err := requestWithRetries(ctx, t, userClient, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
@ -356,7 +374,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s", workspace.Name, proxyTestAppNameOwner), nil)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
@ -368,7 +386,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameOwner), nil)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s/", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
@ -383,13 +401,78 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil)
basePath := fmt.Sprintf("/@%s/%s/apps/%s/", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner)
path := fmt.Sprintf("%s?%s", basePath, proxyTestAppQuery)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, path, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
var sessionTicketCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == codersdk.DevURLSessionTicketCookie {
sessionTicketCookie = c
break
}
}
require.NotNil(t, sessionTicketCookie, "no session ticket in response")
require.Equal(t, sessionTicketCookie.Path, basePath, "incorrect path on session ticket cookie")
// Ensure the session ticket cookie is valid.
ticketClient := codersdk.New(client.URL)
ticketClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
ticketClient.HTTPClient.Transport = client.HTTPClient.Transport
u, err := ticketClient.URL.Parse(path)
require.NoError(t, err)
ticketClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
ticketClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{sessionTicketCookie})
resp, err = requestWithRetries(ctx, t, ticketClient, http.MethodGet, path, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("RedirectsMe", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
loc, err := resp.Location()
require.NoError(t, err)
require.NotContains(t, loc.Path, "@me")
require.Contains(t, loc.Path, "@"+coderdtest.FirstUserParams.Username)
})
t.Run("RedirectsMeUnauthenticated", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
unauthenticatedClient := codersdk.New(client.URL)
unauthenticatedClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
unauthenticatedClient.HTTPClient.Transport = client.HTTPClient.Transport
resp, err := requestWithRetries(ctx, t, unauthenticatedClient, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
loc, err := resp.Location()
require.NoError(t, err)
require.Equal(t, "/login", loc.Path)
})
t.Run("ForwardsIP", func(t *testing.T) {
@ -398,7 +481,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil, func(r *http.Request) {
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s/?%s", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery), nil, func(r *http.Request) {
r.Header.Set("Cf-Connecting-IP", "1.1.1.1")
})
require.NoError(t, err)
@ -416,11 +499,23 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@me/%s/apps/%s/", workspace.Name, proxyTestAppNameFake), nil)
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%s/", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameFake), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusBadGateway, resp.StatusCode)
})
t.Run("NoProxyPort", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/@%s/%s/apps/%d/", coderdtest.FirstUserParams.Username, workspace.Name, 8080), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}
func TestWorkspaceApplicationAuth(t *testing.T) {
@ -706,18 +801,16 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
// proxyURL generates a URL for the proxy subdomain. The default path is a
// slash.
proxyURL := func(t *testing.T, client *codersdk.Client, appNameOrPort interface{}, pathAndQuery ...string) string {
proxyURL := func(t *testing.T, client *codersdk.Client, appSlugOrPort interface{}, pathAndQuery ...string) string {
t.Helper()
var (
appName string
port uint16
)
if val, ok := appNameOrPort.(string); ok {
appName = val
appSlugOrPortStr := ""
if val, ok := appSlugOrPort.(string); ok {
appSlugOrPortStr = val
} else {
port, ok = appNameOrPort.(uint16)
port, ok := appSlugOrPort.(uint16)
require.True(t, ok)
appSlugOrPortStr = strconv.Itoa(int(port))
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
@ -736,8 +829,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
require.NoError(t, err, "get app host")
subdomain := httpapi.ApplicationURL{
AppSlug: appName,
Port: port,
AppSlugOrPort: appSlugOrPortStr,
AgentName: proxyTestAgentName,
WorkspaceName: res.Workspaces[0].Name,
Username: me.Username,
@ -818,13 +910,42 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery), nil)
uStr := proxyURL(t, client, proxyTestAppNameOwner, "/", proxyTestAppQuery)
u, err := url.Parse(uStr)
require.NoError(t, err)
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, uStr, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
var sessionTicketCookie *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == codersdk.DevURLSessionTicketCookie {
sessionTicketCookie = c
break
}
}
require.NotNil(t, sessionTicketCookie, "no session ticket in response")
require.Equal(t, sessionTicketCookie.Path, "/", "incorrect path on session ticket cookie")
// Ensure the session ticket cookie is valid.
ticketClient := codersdk.New(client.URL)
ticketClient.HTTPClient.CheckRedirect = client.HTTPClient.CheckRedirect
ticketClient.HTTPClient.Transport = client.HTTPClient.Transport
ticketClient.HTTPClient.Jar, err = cookiejar.New(nil)
require.NoError(t, err)
ticketClient.HTTPClient.Jar.SetCookies(u, []*http.Cookie{sessionTicketCookie})
resp, err = requestWithRetries(ctx, t, ticketClient, http.MethodGet, uStr, nil)
require.NoError(t, err)
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, proxyTestAppBody, string(body))
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("ProxiesPort", func(t *testing.T) {
@ -1092,7 +1213,7 @@ func TestAppSubdomainLogout(t *testing.T) {
req.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
if c.cookie != "" {
req.AddCookie(&http.Cookie{
Name: httpmw.DevURLSessionTokenCookie,
Name: codersdk.DevURLSessionTokenCookie,
Value: c.cookie,
})
}
@ -1109,7 +1230,7 @@ func TestAppSubdomainLogout(t *testing.T) {
cookies := resp.Cookies()
require.Len(t, cookies, 1, "logout response cookies")
cookie := cookies[0]
require.Equal(t, httpmw.DevURLSessionTokenCookie, cookie.Name)
require.Equal(t, codersdk.DevURLSessionTokenCookie, cookie.Name)
require.Equal(t, "", cookie.Value)
require.True(t, cookie.Expires.Before(time.Now()), "cookie should be expired")
@ -1264,7 +1385,7 @@ func TestAppSharing(t *testing.T) {
u := fmt.Sprintf("/@%s/%s.%s/apps/%s/?%s", username, workspaceName, agentName, appName, proxyTestAppQuery)
if !isPathApp {
subdomain := httpapi.ApplicationURL{
AppSlug: appName,
AppSlugOrPort: appName,
AgentName: agentName,
WorkspaceName: workspaceName,
Username: username,
@ -1512,7 +1633,7 @@ func TestWorkspaceAppsNonCanonicalHeaders(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
u, err := client.URL.Parse(fmt.Sprintf("/@me/%s/apps/%s/?%s", workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery))
u, err := client.URL.Parse(fmt.Sprintf("/@%s/%s/apps/%s/?%s", coderdtest.FirstUserParams.Username, workspace.Name, proxyTestAppNameOwner, proxyTestAppQuery))
require.NoError(t, err)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)

View File

@ -38,6 +38,15 @@ const (
// OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect.
OAuth2RedirectCookie = "oauth_redirect"
// DevURLSessionTokenCookie is the name of the cookie that stores a devurl
// token on app domains.
//nolint:gosec
DevURLSessionTokenCookie = "coder_devurl_session_token"
// DevURLSessionTicketCookie is the name of the cookie that stores a
// temporary JWT that can be used to authenticate instead of the session
// token.
DevURLSessionTicketCookie = "coder_devurl_session_ticket"
// BypassRatelimitHeader is the custom header to use to bypass ratelimits.
// Only owners can bypass rate limits. This is typically used for scale testing.
// nolint: gosec

View File

@ -63,7 +63,7 @@ func New(ctx context.Context, options *Options) (*API, error) {
Github: options.GithubOAuth2Config,
OIDC: options.OIDCConfig,
}
apiKeyMiddleware := httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
DB: options.Database,
OAuth2Configs: oauthConfigs,
RedirectToLogin: false,