mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: move app proxying code to workspaceapps pkg (#6998)
* chore: move app proxying code to workspaceapps pkg
Moves path-app, subdomain-app and reconnecting PTY proxying to the new
workspaceapps.WorkspaceAppServer struct. This is in preparation for
external workspace proxies.
Updates app logout flow to avoid redirecting to coder-logout.${app_host}
on logout. Instead, all subdomain app tokens owned by the logging-out
user will be deleted every time you logout for simplicity sake.
Tests will remain in their original package, pending being moved to an
apptest package (or similar).
Co-authored-by: Steven Masley <stevenmasley@coder.com>
This commit is contained in:
co-authored by
Steven Masley
parent
0069831e8d
commit
eb66cc9f35
@@ -32,16 +32,12 @@ type DBTokenProvider struct {
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
OAuth2Configs *httpmw.OAuth2Configs
|
||||
WorkspaceAgentInactiveTimeout time.Duration
|
||||
TokenSigningKey []byte
|
||||
SigningKey SecurityKey
|
||||
}
|
||||
|
||||
var _ SignedTokenProvider = &DBTokenProvider{}
|
||||
|
||||
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, tokenSigningKey []byte) SignedTokenProvider {
|
||||
if len(tokenSigningKey) != 64 {
|
||||
panic("token signing key must be 64 bytes")
|
||||
}
|
||||
|
||||
func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authorizer, db database.Store, cfg *codersdk.DeploymentValues, oauth2Cfgs *httpmw.OAuth2Configs, workspaceAgentInactiveTimeout time.Duration, signingKey SecurityKey) SignedTokenProvider {
|
||||
if workspaceAgentInactiveTimeout == 0 {
|
||||
workspaceAgentInactiveTimeout = 1 * time.Minute
|
||||
}
|
||||
@@ -54,7 +50,7 @@ func NewDBTokenProvider(log slog.Logger, accessURL *url.URL, authz rbac.Authoriz
|
||||
DeploymentValues: cfg,
|
||||
OAuth2Configs: oauth2Cfgs,
|
||||
WorkspaceAgentInactiveTimeout: workspaceAgentInactiveTimeout,
|
||||
TokenSigningKey: tokenSigningKey,
|
||||
SigningKey: signingKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +58,7 @@ func (p *DBTokenProvider) TokenFromRequest(r *http.Request) (*SignedToken, bool)
|
||||
// Get the existing token from the request.
|
||||
tokenCookie, err := r.Cookie(codersdk.DevURLSignedAppTokenCookie)
|
||||
if err == nil {
|
||||
token, err := ParseToken(p.TokenSigningKey, tokenCookie.Value)
|
||||
token, err := p.SigningKey.VerifySignedToken(tokenCookie.Value)
|
||||
if err == nil {
|
||||
req := token.Request.Normalize()
|
||||
err := req.Validate()
|
||||
@@ -130,9 +126,6 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
token.AgentID = dbReq.Agent.ID
|
||||
token.AppURL = dbReq.AppURL
|
||||
|
||||
// TODO(@deansheather): return an error if the agent is offline or the app
|
||||
// is not running.
|
||||
|
||||
// Verify the user has access to the app.
|
||||
authed, err := p.authorizeRequest(r.Context(), authz, dbReq)
|
||||
if err != nil {
|
||||
@@ -150,7 +143,8 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
// and they aren't signed in.
|
||||
switch appReq.AccessMethod {
|
||||
case AccessMethodPath:
|
||||
// TODO(@deansheather): this doesn't work on moons
|
||||
// TODO(@deansheather): this doesn't work on moons so will need to
|
||||
// be updated to include the access URL as a param
|
||||
httpmw.RedirectToLogin(rw, r, httpmw.SignedOutErrorMessage)
|
||||
case AccessMethodSubdomain:
|
||||
// Redirect to the app auth redirect endpoint with a valid redirect
|
||||
@@ -195,7 +189,7 @@ func (p *DBTokenProvider) CreateToken(ctx context.Context, rw http.ResponseWrite
|
||||
|
||||
// Sign the token.
|
||||
token.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
tokenStr, err := GenerateToken(p.TokenSigningKey, token)
|
||||
tokenStr, err := p.SigningKey.SignToken(token)
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(p.Logger, p.AccessURL, rw, r, &appReq, err, "generate token")
|
||||
return nil, "", false
|
||||
|
||||
@@ -263,7 +263,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Equal(t, codersdk.DevURLSignedAppTokenCookie, cookie.Name)
|
||||
require.Equal(t, req.BasePath, cookie.Path)
|
||||
|
||||
parsedToken, err := workspaceapps.ParseToken(api.AppSigningKey, cookie.Value)
|
||||
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookie.Value)
|
||||
require.NoError(t, err)
|
||||
// normalize expiry
|
||||
require.WithinDuration(t, token.Expiry, parsedToken.Expiry, 2*time.Second)
|
||||
@@ -482,7 +482,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
AgentID: agentID,
|
||||
AppURL: appURL,
|
||||
}
|
||||
badTokenStr, err := workspaceapps.GenerateToken(api.AppSigningKey, badToken)
|
||||
badTokenStr, err := api.AppSecurityKey.SignToken(badToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := workspaceapps.Request{
|
||||
@@ -518,7 +518,7 @@ func Test_ResolveRequest(t *testing.T) {
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, cookies[0].Name, codersdk.DevURLSignedAppTokenCookie)
|
||||
require.NotEqual(t, cookies[0].Value, badTokenStr)
|
||||
parsedToken, err := workspaceapps.ParseToken(api.AppSigningKey, cookies[0].Value)
|
||||
parsedToken, err := api.AppSecurityKey.VerifySignedToken(cookies[0].Value)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, appNameOwner, parsedToken.AppSlugOrPort)
|
||||
})
|
||||
|
||||
@@ -19,14 +19,14 @@ const (
|
||||
RedirectURIQueryParam = "redirect_uri"
|
||||
)
|
||||
|
||||
// ResolveRequest calls TokenProvider to use an existing signed app token in the
|
||||
// ResolveRequest calls SignedTokenProvider to use an existing signed app token in the
|
||||
// request or issue a new one. If it returns a newly minted token, it sets the
|
||||
// cookie for you.
|
||||
func ResolveRequest(log slog.Logger, accessURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
|
||||
func ResolveRequest(log slog.Logger, dashboardURL *url.URL, p SignedTokenProvider, rw http.ResponseWriter, r *http.Request, appReq Request) (*SignedToken, bool) {
|
||||
appReq = appReq.Normalize()
|
||||
err := appReq.Validate()
|
||||
if err != nil {
|
||||
WriteWorkspaceApp500(log, accessURL, rw, r, &appReq, err, "invalid app request")
|
||||
WriteWorkspaceApp500(log, dashboardURL, rw, r, &appReq, err, "invalid app request")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"nhooyr.io/websocket"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/coderd/wsconncache"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/site"
|
||||
)
|
||||
|
||||
const (
|
||||
// This needs to be a super unique query parameter because we don't want to
|
||||
// conflict with query parameters that users may use.
|
||||
//nolint:gosec
|
||||
SubdomainProxyAPIKeyParam = "coder_application_connect_api_key_35e783"
|
||||
// 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
|
||||
// login page.
|
||||
// It is important that this URL can never match a valid app hostname.
|
||||
//
|
||||
// DEPRECATED: we no longer use this, but we still redirect from it to the
|
||||
// main login page.
|
||||
appLogoutHostname = "coder-logout"
|
||||
)
|
||||
|
||||
// nonCanonicalHeaders is a map from "canonical" headers to the actual header we
|
||||
// should send to the app in the workspace. Some headers (such as the websocket
|
||||
// upgrade headers from RFC 6455) are not canonical according to the HTTP/1
|
||||
// spec. Golang has said that they will not add custom cases for these headers,
|
||||
// so we need to do it ourselves.
|
||||
//
|
||||
// Some apps our customers use are sensitive to the case of these headers.
|
||||
//
|
||||
// https://github.com/golang/go/issues/18495
|
||||
var nonCanonicalHeaders = map[string]string{
|
||||
"Sec-Websocket-Accept": "Sec-WebSocket-Accept",
|
||||
"Sec-Websocket-Extensions": "Sec-WebSocket-Extensions",
|
||||
"Sec-Websocket-Key": "Sec-WebSocket-Key",
|
||||
"Sec-Websocket-Protocol": "Sec-WebSocket-Protocol",
|
||||
"Sec-Websocket-Version": "Sec-WebSocket-Version",
|
||||
}
|
||||
|
||||
// Server serves workspace apps endpoints, including:
|
||||
// - Path-based apps
|
||||
// - Subdomain app middleware
|
||||
// - Workspace reconnecting-pty (aka. web terminal)
|
||||
type Server struct {
|
||||
Logger slog.Logger
|
||||
|
||||
// DashboardURL should be a url to the coderd dashboard. This can be the
|
||||
// same as the AccessURL if the Server is embedded.
|
||||
DashboardURL *url.URL
|
||||
AccessURL *url.URL
|
||||
// Hostname should be the wildcard hostname to use for workspace
|
||||
// applications INCLUDING the asterisk, (optional) suffix and leading dot.
|
||||
// It will use the same scheme and port number as the access URL.
|
||||
// E.g. "*.apps.coder.com" or "*-apps.coder.com".
|
||||
Hostname string
|
||||
// HostnameRegex contains the regex version of Hostname as generated by
|
||||
// httpapi.CompileHostnamePattern(). It MUST be set if Hostname is set.
|
||||
HostnameRegex *regexp.Regexp
|
||||
DeploymentValues *codersdk.DeploymentValues
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
WorkspaceConnCache *wsconncache.Cache
|
||||
AppSecurityKey SecurityKey
|
||||
|
||||
websocketWaitMutex sync.Mutex
|
||||
websocketWaitGroup sync.WaitGroup
|
||||
}
|
||||
|
||||
// Close waits for all reconnecting-pty WebSocket connections to drain before
|
||||
// returning.
|
||||
func (s *Server) Close() error {
|
||||
s.websocketWaitMutex.Lock()
|
||||
s.websocketWaitGroup.Wait()
|
||||
s.websocketWaitMutex.Unlock()
|
||||
|
||||
// The caller must close the SignedTokenProvider (if necessary) and the
|
||||
// wsconncache.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Attach(r chi.Router) {
|
||||
servePathApps := func(r chi.Router) {
|
||||
r.HandleFunc("/*", s.workspaceAppsProxyPath)
|
||||
}
|
||||
|
||||
// %40 is the encoded character of the @ symbol. VS Code Web does
|
||||
// not handle character encoding properly, so it's safe to assume
|
||||
// other applications might not as well.
|
||||
r.Route("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}", servePathApps)
|
||||
r.Route("/@{user}/{workspace_and_agent}/apps/{workspaceapp}", servePathApps)
|
||||
|
||||
r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", s.workspaceAgentPTY)
|
||||
}
|
||||
|
||||
// workspaceAppsProxyPath proxies requests to a workspace application
|
||||
// through a relative URL path.
|
||||
func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
|
||||
if s.DeploymentValues.DisablePathApps.Value() {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusUnauthorized,
|
||||
Title: "Unauthorized",
|
||||
Description: "Path-based applications are disabled on this Coder deployment by the administrator.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// We don't support @me in path apps since it requires the database to
|
||||
// lookup the username from token. We used to redirect by doing this lookup.
|
||||
if chi.URLParam(r, "user") == codersdk.Me {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusNotFound,
|
||||
Title: "Application Not Found",
|
||||
Description: "Applications must be accessed with the full username, not @me.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the real path that was hit. The * URL parameter in Chi will not
|
||||
// include the leading slash if it was present, so we need to add it back.
|
||||
chiPath := chi.URLParam(r, "*")
|
||||
basePath := strings.TrimSuffix(r.URL.Path, chiPath)
|
||||
if strings.HasSuffix(basePath, "/") {
|
||||
chiPath = "/" + chiPath
|
||||
}
|
||||
|
||||
// ResolveRequest will only return a new signed token if the actor has the RBAC
|
||||
// permissions to connect to a workspace.
|
||||
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: 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
|
||||
}
|
||||
|
||||
s.proxyWorkspaceApp(rw, r, *token, chiPath)
|
||||
}
|
||||
|
||||
// SubdomainAppMW handles subdomain-based application proxy requests (aka.
|
||||
// DevURLs in Coder V1).
|
||||
//
|
||||
// There are a lot of paths here:
|
||||
// 1. If api.Hostname is not set then we pass on.
|
||||
// 2. If we can't read the request hostname then we return a 400.
|
||||
// 3. If the request hostname matches api.AccessURL then we pass on.
|
||||
// 5. We split the subdomain into the subdomain and the "rest". If there are no
|
||||
// periods in the hostname then we pass on.
|
||||
// 5. We parse the subdomain into a httpapi.ApplicationURL struct. If we
|
||||
// encounter an error:
|
||||
// a. If the "rest" does not match api.Hostname then we pass on;
|
||||
// b. Otherwise, we return a 400.
|
||||
// 6. Finally, we verify that the "rest" matches api.Hostname, else we
|
||||
// return a 404.
|
||||
//
|
||||
// Rationales for each of the above steps:
|
||||
// 1. We pass on if api.Hostname is not set to avoid returning any errors if
|
||||
// `--app-hostname` is not configured.
|
||||
// 2. Every request should have a valid Host header anyways.
|
||||
// 3. We pass on if the request hostname matches api.AccessURL so we can
|
||||
// support having the access URL be at the same level as the application
|
||||
// base hostname.
|
||||
// 4. We pass on if there are no periods in the hostname as application URLs
|
||||
// must be a subdomain of a hostname, which implies there must be at least
|
||||
// one period.
|
||||
// 5. a. If the request subdomain is not a valid application URL, and the
|
||||
// "rest" does not match api.Hostname, then it is very unlikely that
|
||||
// the request was intended for this handler. We pass on.
|
||||
// b. If the request subdomain is not a valid application URL, but the
|
||||
// "rest" matches api.Hostname, then we return a 400 because the
|
||||
// request is probably a typo or something.
|
||||
// 6. We finally verify that the "rest" matches api.Hostname for security
|
||||
// purposes regarding re-authentication and application proxy session
|
||||
// tokens.
|
||||
func (s *Server) SubdomainAppMW(middlewares ...func(http.Handler) http.Handler) 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()
|
||||
|
||||
// Step 1: Pass on if subdomain-based application proxying is not
|
||||
// configured.
|
||||
if s.Hostname == "" || s.HostnameRegex == nil {
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Get the request Host.
|
||||
host := httpapi.RequestHost(r)
|
||||
if host == "" {
|
||||
if r.URL.Path == "/derp" {
|
||||
// The /derp endpoint is used by wireguard clients to tunnel
|
||||
// through coderd. For some reason these requests don't set
|
||||
// a Host header properly sometimes in tests (no idea how),
|
||||
// which causes this path to get hit.
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Could not determine request Host.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Steps 3-6: Parse application from subdomain.
|
||||
app, ok := s.parseHostname(rw, r, next, host)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 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 := s.AppSecurityKey.DecryptAPIKey(encryptedAPIKey)
|
||||
if err != nil {
|
||||
s.Logger.Debug(ctx, "could not decrypt API key", slog.Error(err))
|
||||
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: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.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
|
||||
}
|
||||
|
||||
token, ok := ResolveRequest(s.Logger, s.DashboardURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: 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) {
|
||||
s.proxyWorkspaceApp(rw, r, *token, r.URL.Path)
|
||||
})).ServeHTTP(rw, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// parseHostname will return if a given request is attempting to access a
|
||||
// workspace app via a subdomain. If it is, the hostname of the request is parsed
|
||||
// into an httpapi.ApplicationURL and true is returned. If the request is not
|
||||
// accessing a workspace app, then the next handler is called and false is
|
||||
// returned.
|
||||
func (s *Server) parseHostname(rw http.ResponseWriter, r *http.Request, next http.Handler, host string) (httpapi.ApplicationURL, bool) {
|
||||
// Check if the hostname matches either of the access URLs. If it does, the
|
||||
// user was definitely trying to connect to the dashboard/API or a
|
||||
// path-based app.
|
||||
if httpapi.HostnamesMatch(s.DashboardURL.Hostname(), host) || httpapi.HostnamesMatch(s.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(s.HostnameRegex, 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 the deprecated logout flow. If so, we
|
||||
// just redirect to the main access URL.
|
||||
if subdomain == appLogoutHostname {
|
||||
http.Redirect(rw, r, s.AccessURL.String(), http.StatusTemporaryRedirect)
|
||||
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: s.DashboardURL.String(),
|
||||
})
|
||||
return httpapi.ApplicationURL{}, false
|
||||
}
|
||||
|
||||
return app, true
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Server) setWorkspaceAppCookie(rw http.ResponseWriter, r *http.Request, token string) bool {
|
||||
hostSplit := strings.SplitN(s.Hostname, ".", 2)
|
||||
if len(hostSplit) != 2 {
|
||||
// This should be impossible as we verify the app hostname on
|
||||
// startup, but we'll check anyways.
|
||||
s.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", s.Hostname))
|
||||
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: s.DashboardURL.String(),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Set the app cookie for all subdomains of s.Hostname. We don't set an
|
||||
// expiration because the key in the database already has an expiration, and
|
||||
// expired tokens don't affect the user experience (they get auto-redirected
|
||||
// to re-smuggle the API key).
|
||||
cookieHost := "." + hostSplit[1]
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
Name: codersdk.DevURLSessionTokenCookie,
|
||||
Value: token,
|
||||
Domain: cookieHost,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.DeploymentValues.SecureAuthCookie.Value(),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) proxyWorkspaceApp(rw http.ResponseWriter, r *http.Request, appToken SignedToken, path string) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Filter IP headers from untrusted origins.
|
||||
httpmw.FilterUntrustedOriginHeaders(s.RealIPConfig, r)
|
||||
|
||||
// Ensure proper IP headers get sent to the forwarded application.
|
||||
err := httpmw.EnsureXForwardedForHeader(r)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
appURL, err := url.Parse(appToken.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", appToken.AppURL, err.Error()),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify that the port is allowed. See the docs above
|
||||
// `codersdk.MinimumListeningPort` for more details.
|
||||
port := appURL.Port()
|
||||
if port != "" {
|
||||
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.", appToken.AppURL, port),
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if portInt < codersdk.WorkspaceAgentMinimumListeningPort {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Application port %d is not permitted. Coder reserves ports less than %d for internal use.", portInt, codersdk.WorkspaceAgentMinimumListeningPort),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure path and query parameter correctness.
|
||||
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 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
|
||||
// client-side applications require the query parameters to render
|
||||
// properly. With code-server, this is the "folder" param.
|
||||
r.URL.RawQuery = appURL.RawQuery
|
||||
http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
r.URL.Path = path
|
||||
appURL.RawQuery = ""
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(appURL)
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadGateway,
|
||||
Title: "Bad Gateway",
|
||||
Description: "Failed to proxy request to application: " + err.Error(),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
conn, release, err := s.WorkspaceConnCache.Acquire(appToken.AgentID)
|
||||
if err != nil {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadGateway,
|
||||
Title: "Bad Gateway",
|
||||
Description: "Could not connect to workspace agent: " + err.Error(),
|
||||
RetryEnabled: true,
|
||||
DashboardURL: s.DashboardURL.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
proxy.Transport = conn.HTTPTransport()
|
||||
|
||||
// This strips the session token from a workspace app request.
|
||||
cookieHeaders := r.Header.Values("Cookie")[:]
|
||||
r.Header.Del("Cookie")
|
||||
for _, cookieHeader := range cookieHeaders {
|
||||
r.Header.Add("Cookie", httpapi.StripCoderCookies(cookieHeader))
|
||||
}
|
||||
|
||||
// Convert canonicalized headers to their non-canonicalized counterparts.
|
||||
// See the comment on `nonCanonicalHeaders` for more information on why this
|
||||
// is necessary.
|
||||
for k, v := range r.Header {
|
||||
if n, ok := nonCanonicalHeaders[k]; ok {
|
||||
r.Header.Del(k)
|
||||
r.Header[n] = v
|
||||
}
|
||||
}
|
||||
|
||||
// end span so we don't get long lived trace data
|
||||
tracing.EndHTTPSpan(r, http.StatusOK, trace.SpanFromContext(ctx))
|
||||
|
||||
proxy.ServeHTTP(rw, r)
|
||||
}
|
||||
|
||||
// workspaceAgentPTY spawns a PTY and pipes it over a WebSocket.
|
||||
// This is used for the web terminal.
|
||||
//
|
||||
// @Summary Open PTY to workspace agent
|
||||
// @ID open-pty-to-workspace-agent
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Agents
|
||||
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
|
||||
// @Success 101
|
||||
// @Router /workspaceagents/{workspaceagent}/pty [get]
|
||||
func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
s.websocketWaitMutex.Lock()
|
||||
s.websocketWaitGroup.Add(1)
|
||||
s.websocketWaitMutex.Unlock()
|
||||
defer s.websocketWaitGroup.Done()
|
||||
|
||||
appToken, ok := ResolveRequest(s.Logger, s.AccessURL, s.SignedTokenProvider, rw, r, Request{
|
||||
AccessMethod: AccessMethodTerminal,
|
||||
BasePath: r.URL.Path,
|
||||
AgentNameOrID: chi.URLParam(r, "workspaceagent"),
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
values := r.URL.Query()
|
||||
parser := httpapi.NewQueryParamParser()
|
||||
reconnect := parser.Required("reconnect").UUID(values, uuid.New(), "reconnect")
|
||||
height := parser.UInt(values, 80, "height")
|
||||
width := parser.UInt(values, 80, "width")
|
||||
if len(parser.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid query parameters.",
|
||||
Validations: parser.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to accept websocket.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, wsNetConn := WebsocketNetConn(ctx, conn, websocket.MessageBinary)
|
||||
defer wsNetConn.Close() // Also closes conn.
|
||||
|
||||
go httpapi.Heartbeat(ctx, conn)
|
||||
|
||||
agentConn, release, err := s.WorkspaceConnCache.Acquire(appToken.AgentID)
|
||||
if err != nil {
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
ptNetConn, err := agentConn.ReconnectingPTY(ctx, reconnect, uint16(height), uint16(width), r.URL.Query().Get("command"))
|
||||
if err != nil {
|
||||
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
|
||||
return
|
||||
}
|
||||
defer ptNetConn.Close()
|
||||
agent.Bicopy(ctx, wsNetConn, ptNetConn)
|
||||
}
|
||||
|
||||
// wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func
|
||||
// is called if a read or write error is encountered.
|
||||
type wsNetConn struct {
|
||||
cancel context.CancelFunc
|
||||
net.Conn
|
||||
}
|
||||
|
||||
func (c *wsNetConn) Read(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Read(b)
|
||||
if err != nil {
|
||||
c.cancel()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *wsNetConn) Write(b []byte) (n int, err error) {
|
||||
n, err = c.Conn.Write(b)
|
||||
if err != nil {
|
||||
c.cancel()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *wsNetConn) Close() error {
|
||||
defer c.cancel()
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// WebsocketNetConn wraps websocket.NetConn and returns a context that
|
||||
// is tied to the parent context and the lifetime of the conn. Any error
|
||||
// during read or write will cancel the context, but not close the
|
||||
// conn. Close should be called to release context resources.
|
||||
func WebsocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websocket.MessageType) (context.Context, net.Conn) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
nc := websocket.NetConn(ctx, conn, msgType)
|
||||
return ctx, &wsNetConn{
|
||||
cancel: cancel,
|
||||
Conn: nc,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package workspaceapps_test
|
||||
|
||||
// NOTE: for now, app proxying tests are still in their old locations, pending
|
||||
// being moved to their own package.
|
||||
//
|
||||
// See:
|
||||
// - coderd/workspaceapps_test.go
|
||||
// - coderd/workspaceagents_test.go (for PTY)
|
||||
@@ -1,102 +0,0 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
const tokenSigningAlgorithm = jose.HS512
|
||||
|
||||
// SignedToken is the struct data contained inside a workspace app JWE. It
|
||||
// contains the details of the workspace app that the token is valid for to
|
||||
// avoid database queries.
|
||||
type SignedToken struct {
|
||||
// Request details.
|
||||
Request `json:"request"`
|
||||
|
||||
// Trusted resolved details.
|
||||
Expiry time.Time `json:"expiry"` // set by GenerateToken if unset
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
AppURL string `json:"app_url"`
|
||||
}
|
||||
|
||||
// MatchesRequest returns true if the token matches the request. Any token that
|
||||
// does not match the request should be considered invalid.
|
||||
func (t SignedToken) MatchesRequest(req Request) bool {
|
||||
return t.AccessMethod == req.AccessMethod &&
|
||||
t.BasePath == req.BasePath &&
|
||||
t.UsernameOrID == req.UsernameOrID &&
|
||||
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
|
||||
t.AgentNameOrID == req.AgentNameOrID &&
|
||||
t.AppSlugOrPort == req.AppSlugOrPort
|
||||
}
|
||||
|
||||
// GenerateToken generates a signed workspace app token with the given key and
|
||||
// payload. If the payload doesn't have an expiry, it will be set to the current
|
||||
// time plus the default expiry.
|
||||
func GenerateToken(key []byte, payload SignedToken) (string, error) {
|
||||
if payload.Expiry.IsZero() {
|
||||
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: tokenSigningAlgorithm,
|
||||
Key: key,
|
||||
}, 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
|
||||
}
|
||||
|
||||
// ParseToken parses a signed workspace app token with the given key and returns
|
||||
// the payload. If the token is invalid or expired, an error is returned.
|
||||
func ParseToken(key []byte, str string) (SignedToken, error) {
|
||||
object, err := jose.ParseSigned(str)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
|
||||
}
|
||||
if len(object.Signatures) != 1 {
|
||||
return SignedToken{}, xerrors.New("expected 1 signature")
|
||||
}
|
||||
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
|
||||
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
|
||||
}
|
||||
|
||||
output, err := object.Verify(key)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
|
||||
}
|
||||
|
||||
var tok SignedToken
|
||||
err = json.Unmarshal(output, &tok)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if tok.Expiry.Before(time.Now()) {
|
||||
return SignedToken{}, xerrors.New("signed app token expired")
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenSigningAlgorithm = jose.HS512
|
||||
apiKeyEncryptionAlgorithm = jose.A256GCMKW
|
||||
)
|
||||
|
||||
// SignedToken is the struct data contained inside a workspace app JWE. It
|
||||
// contains the details of the workspace app that the token is valid for to
|
||||
// avoid database queries.
|
||||
type SignedToken struct {
|
||||
// Request details.
|
||||
Request `json:"request"`
|
||||
|
||||
// Trusted resolved details.
|
||||
Expiry time.Time `json:"expiry"` // set by GenerateToken if unset
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
AppURL string `json:"app_url"`
|
||||
}
|
||||
|
||||
// MatchesRequest returns true if the token matches the request. Any token that
|
||||
// does not match the request should be considered invalid.
|
||||
func (t SignedToken) MatchesRequest(req Request) bool {
|
||||
return t.AccessMethod == req.AccessMethod &&
|
||||
t.BasePath == req.BasePath &&
|
||||
t.UsernameOrID == req.UsernameOrID &&
|
||||
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
|
||||
t.AgentNameOrID == req.AgentNameOrID &&
|
||||
t.AppSlugOrPort == req.AppSlugOrPort
|
||||
}
|
||||
|
||||
// SecurityKey is used for signing and encrypting app tokens and API keys.
|
||||
//
|
||||
// The first 64 bytes of the key are used for signing tokens with HMAC-SHA256,
|
||||
// and the last 32 bytes are used for encrypting API keys with AES-256-GCM.
|
||||
// We use a single key for both operations to avoid having to store and manage
|
||||
// two keys.
|
||||
type SecurityKey [96]byte
|
||||
|
||||
func (k SecurityKey) signingKey() []byte {
|
||||
return k[:64]
|
||||
}
|
||||
|
||||
func (k SecurityKey) encryptionKey() []byte {
|
||||
return k[64:]
|
||||
}
|
||||
|
||||
func KeyFromString(str string) (SecurityKey, error) {
|
||||
var key SecurityKey
|
||||
decoded, err := hex.DecodeString(str)
|
||||
if err != nil {
|
||||
return key, xerrors.Errorf("decode key: %w", err)
|
||||
}
|
||||
if len(decoded) != len(key) {
|
||||
return key, xerrors.Errorf("expected key to be %d bytes, got %d", len(key), len(decoded))
|
||||
}
|
||||
copy(key[:], decoded)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// SignToken generates a signed workspace app token with the given payload. If
|
||||
// the payload doesn't have an expiry, it will be set to the current time plus
|
||||
// the default expiry.
|
||||
func (k SecurityKey) SignToken(payload SignedToken) (string, error) {
|
||||
if payload.Expiry.IsZero() {
|
||||
payload.Expiry = time.Now().Add(DefaultTokenExpiry)
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: tokenSigningAlgorithm,
|
||||
Key: k.signingKey(),
|
||||
}, 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
|
||||
}
|
||||
|
||||
// VerifySignedToken parses a signed workspace app token with the given key and
|
||||
// returns the payload. If the token is invalid or expired, an error is
|
||||
// returned.
|
||||
func (k SecurityKey) VerifySignedToken(str string) (SignedToken, error) {
|
||||
object, err := jose.ParseSigned(str)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("parse JWS: %w", err)
|
||||
}
|
||||
if len(object.Signatures) != 1 {
|
||||
return SignedToken{}, xerrors.New("expected 1 signature")
|
||||
}
|
||||
if object.Signatures[0].Header.Algorithm != string(tokenSigningAlgorithm) {
|
||||
return SignedToken{}, xerrors.Errorf("expected token signing algorithm to be %q, got %q", tokenSigningAlgorithm, object.Signatures[0].Header.Algorithm)
|
||||
}
|
||||
|
||||
output, err := object.Verify(k.signingKey())
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("verify JWS: %w", err)
|
||||
}
|
||||
|
||||
var tok SignedToken
|
||||
err = json.Unmarshal(output, &tok)
|
||||
if err != nil {
|
||||
return SignedToken{}, xerrors.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if tok.Expiry.Before(time.Now()) {
|
||||
return SignedToken{}, xerrors.New("signed app token expired")
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
type EncryptedAPIKeyPayload struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// EncryptAPIKey encrypts an API key for subdomain token smuggling.
|
||||
func (k SecurityKey) EncryptAPIKey(payload EncryptedAPIKeyPayload) (string, error) {
|
||||
if payload.APIKey == "" {
|
||||
return "", xerrors.New("API key is empty")
|
||||
}
|
||||
if payload.ExpiresAt.IsZero() {
|
||||
// Very short expiry as these keys are only used once as part of an
|
||||
// automatic redirection flow.
|
||||
payload.ExpiresAt = database.Now().Add(time.Minute)
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// JWEs seem to apply a nonce themselves.
|
||||
encrypter, err := jose.NewEncrypter(
|
||||
jose.A256GCM,
|
||||
jose.Recipient{
|
||||
Algorithm: apiKeyEncryptionAlgorithm,
|
||||
Key: k.encryptionKey(),
|
||||
},
|
||||
&jose.EncrypterOptions{
|
||||
Compression: jose.DEFLATE,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("initializer jose encrypter: %w", err)
|
||||
}
|
||||
encryptedObject, err := encrypter.Encrypt(payloadBytes)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("encrypt jwe: %w", err)
|
||||
}
|
||||
|
||||
encrypted := encryptedObject.FullSerialize()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(encrypted)), nil
|
||||
}
|
||||
|
||||
// DecryptAPIKey undoes EncryptAPIKey and is used in the subdomain app handler.
|
||||
func (k SecurityKey) DecryptAPIKey(encryptedAPIKey string) (string, error) {
|
||||
encrypted, err := base64.RawURLEncoding.DecodeString(encryptedAPIKey)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("base64 decode encrypted API key: %w", err)
|
||||
}
|
||||
|
||||
object, err := jose.ParseEncrypted(string(encrypted))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse encrypted API key: %w", err)
|
||||
}
|
||||
if object.Header.Algorithm != string(apiKeyEncryptionAlgorithm) {
|
||||
return "", xerrors.Errorf("expected API key encryption algorithm to be %q, got %q", apiKeyEncryptionAlgorithm, object.Header.Algorithm)
|
||||
}
|
||||
|
||||
// Decrypt using the hashed secret.
|
||||
decrypted, err := object.Decrypt(k.encryptionKey())
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("decrypt API key: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal the payload.
|
||||
var payload EncryptedAPIKeyPayload
|
||||
if err := json.Unmarshal(decrypted, &payload); err != nil {
|
||||
return "", xerrors.Errorf("unmarshal decrypted payload: %w", err)
|
||||
}
|
||||
|
||||
// Validate expiry.
|
||||
if payload.ExpiresAt.Before(database.Now()) {
|
||||
return "", xerrors.New("encrypted API key expired")
|
||||
}
|
||||
|
||||
return payload.APIKey, nil
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
package workspaceapps_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/workspaceapps"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
)
|
||||
|
||||
func Test_TokenMatchesRequest(t *testing.T) {
|
||||
@@ -164,7 +166,7 @@ func Test_GenerateToken(t *testing.T) {
|
||||
t.Run("SetExpiry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tokenStr, err := workspaceapps.GenerateToken(coderdtest.AppSigningKey, workspaceapps.SignedToken{
|
||||
tokenStr, err := coderdtest.AppSecurityKey.SignToken(workspaceapps.SignedToken{
|
||||
Request: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
@@ -182,7 +184,7 @@ func Test_GenerateToken(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, tokenStr)
|
||||
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.WithinDuration(t, time.Now().Add(time.Minute), token.Expiry, 15*time.Second)
|
||||
@@ -260,13 +262,13 @@ func Test_GenerateToken(t *testing.T) {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
str, err := workspaceapps.GenerateToken(coderdtest.AppSigningKey, c.token)
|
||||
str, err := coderdtest.AppSecurityKey.SignToken(c.token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tokens aren't deterministic as they have a random nonce, so we
|
||||
// can't compare them directly.
|
||||
|
||||
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, str)
|
||||
token, err := coderdtest.AppSecurityKey.VerifySignedToken(str)
|
||||
if c.parseErrContains != "" {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, c.parseErrContains)
|
||||
@@ -289,7 +291,7 @@ func Test_ParseToken(t *testing.T) {
|
||||
t.Run("InvalidJWS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, "invalid")
|
||||
token, err := coderdtest.AppSecurityKey.VerifySignedToken("invalid")
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "parse JWS")
|
||||
require.Equal(t, workspaceapps.SignedToken{}, token)
|
||||
@@ -299,12 +301,14 @@ func Test_ParseToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a valid token using a different key.
|
||||
otherKey, err := hex.DecodeString("62656566646561646265656664656164626565666465616462656566646561646265656664656164626565666465616462656566646561646265656664656164")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, coderdtest.AppSigningKey, otherKey)
|
||||
require.Len(t, otherKey, 64)
|
||||
var otherKey workspaceapps.SecurityKey
|
||||
copy(otherKey[:], coderdtest.AppSecurityKey[:])
|
||||
for i := range otherKey {
|
||||
otherKey[i] ^= 0xff
|
||||
}
|
||||
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
|
||||
|
||||
tokenStr, err := workspaceapps.GenerateToken(otherKey, workspaceapps.SignedToken{
|
||||
tokenStr, err := otherKey.SignToken(workspaceapps.SignedToken{
|
||||
Request: workspaceapps.Request{
|
||||
AccessMethod: workspaceapps.AccessMethodPath,
|
||||
BasePath: "/app",
|
||||
@@ -323,7 +327,7 @@ func Test_ParseToken(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the token is invalid.
|
||||
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, tokenStr)
|
||||
token, err := coderdtest.AppSecurityKey.VerifySignedToken(tokenStr)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "verify JWS")
|
||||
require.Equal(t, workspaceapps.SignedToken{}, token)
|
||||
@@ -333,16 +337,86 @@ func Test_ParseToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a signature for an invalid body.
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSigningKey}, nil)
|
||||
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS512, Key: coderdtest.AppSecurityKey[:64]}, nil)
|
||||
require.NoError(t, err)
|
||||
signedObject, err := signer.Sign([]byte("hi"))
|
||||
require.NoError(t, err)
|
||||
serialized, err := signedObject.CompactSerialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := workspaceapps.ParseToken(coderdtest.AppSigningKey, serialized)
|
||||
token, err := coderdtest.AppSecurityKey.VerifySignedToken(serialized)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "unmarshal payload")
|
||||
require.Equal(t, workspaceapps.SignedToken{}, token)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPIKeyEncryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
genAPIKey := func(t *testing.T) string {
|
||||
id, _ := cryptorand.String(10)
|
||||
secret, _ := cryptorand.String(22)
|
||||
|
||||
return fmt.Sprintf("%s-%s", id, secret)
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := genAPIKey(t)
|
||||
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
|
||||
APIKey: key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, key, decryptedKey)
|
||||
})
|
||||
|
||||
t.Run("Verifies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Expiry", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := genAPIKey(t)
|
||||
encrypted, err := coderdtest.AppSecurityKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
|
||||
APIKey: key,
|
||||
ExpiresAt: database.Now().Add(-1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "expired")
|
||||
require.Empty(t, decryptedKey)
|
||||
})
|
||||
|
||||
t.Run("EncryptionKey", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a valid token using a different key.
|
||||
var otherKey workspaceapps.SecurityKey
|
||||
copy(otherKey[:], coderdtest.AppSecurityKey[:])
|
||||
for i := range otherKey {
|
||||
otherKey[i] ^= 0xff
|
||||
}
|
||||
require.NotEqual(t, coderdtest.AppSecurityKey, otherKey)
|
||||
|
||||
// Encrypt with the other key.
|
||||
key := genAPIKey(t)
|
||||
encrypted, err := otherKey.EncryptAPIKey(workspaceapps.EncryptedAPIKeyPayload{
|
||||
APIKey: key,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decrypt with the original key.
|
||||
decryptedKey, err := coderdtest.AppSecurityKey.DecryptAPIKey(encrypted)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "decrypt API key")
|
||||
require.Empty(t, decryptedKey)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user