Files
coder/coderd/workspaceapps/token.go
Jon Ayers cd890aa3a0 feat: enable key rotation (#15066)
This PR contains the remaining logic necessary to hook up key rotation
to the product.
2024-10-25 17:14:35 +01:00

132 lines
3.8 KiB
Go

package workspaceapps
import (
"net/http"
"strings"
"time"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/jwtutils"
"github.com/coder/coder/v2/codersdk"
)
// 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 {
jwtutils.RegisteredClaims
// Request details.
Request `json:"request"`
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 {
tokenBasePath := t.Request.BasePath
if !strings.HasSuffix(tokenBasePath, "/") {
tokenBasePath += "/"
}
reqBasePath := req.BasePath
if !strings.HasSuffix(reqBasePath, "/") {
reqBasePath += "/"
}
return t.AccessMethod == req.AccessMethod &&
tokenBasePath == reqBasePath &&
t.Prefix == req.Prefix &&
t.UsernameOrID == req.UsernameOrID &&
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
t.AgentNameOrID == req.AgentNameOrID &&
t.AppSlugOrPort == req.AppSlugOrPort
}
type EncryptedAPIKeyPayload struct {
jwtutils.RegisteredClaims
APIKey string `json:"api_key"`
}
func (e *EncryptedAPIKeyPayload) Fill(now time.Time) {
e.Issuer = "coderd"
e.Audience = jwt.Audience{"wsproxy"}
e.Expiry = jwt.NewNumericDate(now.Add(time.Minute))
e.NotBefore = jwt.NewNumericDate(now.Add(-time.Minute))
}
func (e EncryptedAPIKeyPayload) Validate(ex jwt.Expected) error {
if e.NotBefore == nil {
return xerrors.Errorf("not before is required")
}
ex.Issuer = "coderd"
ex.AnyAudience = jwt.Audience{"wsproxy"}
return e.RegisteredClaims.Validate(ex)
}
// FromRequest returns the signed token from the request, if it exists and is
// valid. The caller must check that the token matches the request.
func FromRequest(r *http.Request, mgr cryptokeys.SigningKeycache) (*SignedToken, bool) {
// Get all signed app tokens from the request. This includes the query
// parameter and all matching cookies sent with the request. If there are
// somehow multiple signed app token cookies, we want to try all of them
// (up to 4). The first one that is valid is used.
//
// Browsers will send all cookies in the request, even if there are multiple
// with the same name on different paths.
//
// If using a query parameter the request MUST be a terminal request. We use
// this to support cross-domain terminal access for the web terminal.
var (
tokens = []string{}
hasQueryParam = false
)
if q := r.URL.Query().Get(codersdk.SignedAppTokenQueryParameter); q != "" {
hasQueryParam = true
tokens = append(tokens, q)
}
for _, cookie := range r.Cookies() {
if cookie.Name == codersdk.SignedAppTokenCookie {
tokens = append(tokens, cookie.Value)
}
}
if len(tokens) > 4 {
tokens = tokens[:4]
}
ctx := r.Context()
for _, tokenStr := range tokens {
var token SignedToken
err := jwtutils.Verify(ctx, mgr, tokenStr, &token, jwtutils.WithVerifyExpected(jwt.Expected{
Time: time.Now(),
}))
if err == nil {
req := token.Request.Normalize()
if hasQueryParam && req.AccessMethod != AccessMethodTerminal {
// The request must be a terminal request if we're using a
// query parameter.
return nil, false
}
err := req.Check()
if err == nil {
// The request has a valid signed app token, which is a valid
// token signed by us. The caller must check that it matches
// the request.
return &token, true
}
}
}
return nil, false
}