mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: use JWT ticket to avoid DB queries on apps (#6148)
Issue a JWT ticket on the first request with a short expiry that contains details about which workspace/agent/app combo the ticket is valid for.
This commit is contained in:
102
coderd/workspaceapps/ticket.go
Normal file
102
coderd/workspaceapps/ticket.go
Normal file
@ -0,0 +1,102 @@
|
||||
package workspaceapps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
)
|
||||
|
||||
const ticketSigningAlgorithm = jose.HS512
|
||||
|
||||
// Ticket is the struct data contained inside a workspace app ticket JWE. It
|
||||
// contains the details of the workspace app that the ticket is valid for to
|
||||
// avoid database queries.
|
||||
//
|
||||
// The JSON field names are short to reduce the size of the ticket.
|
||||
type Ticket struct {
|
||||
// Request details.
|
||||
AccessMethod AccessMethod `json:"access_method"`
|
||||
UsernameOrID string `json:"username_or_id"`
|
||||
WorkspaceNameOrID string `json:"workspace_name_or_id"`
|
||||
AgentNameOrID string `json:"agent_name_or_id"`
|
||||
AppSlugOrPort string `json:"app_slug_or_port"`
|
||||
|
||||
// Trusted resolved details.
|
||||
Expiry int64 `json:"expiry"` // set by GenerateTicket if unset
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
WorkspaceID uuid.UUID `json:"workspace_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
AppURL string `json:"app_url"`
|
||||
}
|
||||
|
||||
func (t Ticket) MatchesRequest(req Request) bool {
|
||||
return t.AccessMethod == req.AccessMethod &&
|
||||
t.UsernameOrID == req.UsernameOrID &&
|
||||
t.WorkspaceNameOrID == req.WorkspaceNameOrID &&
|
||||
t.AgentNameOrID == req.AgentNameOrID &&
|
||||
t.AppSlugOrPort == req.AppSlugOrPort
|
||||
}
|
||||
|
||||
func (p *Provider) GenerateTicket(payload Ticket) (string, error) {
|
||||
if payload.Expiry == 0 {
|
||||
payload.Expiry = time.Now().Add(TicketExpiry).Unix()
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("marshal payload to JSON: %w", err)
|
||||
}
|
||||
|
||||
// We use symmetric signing with an RSA key to support satellites in the
|
||||
// future.
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: ticketSigningAlgorithm,
|
||||
Key: p.TicketSigningKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("create signer: %w", err)
|
||||
}
|
||||
|
||||
signedObject, err := signer.Sign(payloadBytes)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("sign payload: %w", err)
|
||||
}
|
||||
|
||||
serialized, err := signedObject.CompactSerialize()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("serialize JWS: %w", err)
|
||||
}
|
||||
|
||||
return serialized, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ParseTicket(ticketStr string) (Ticket, error) {
|
||||
object, err := jose.ParseSigned(ticketStr)
|
||||
if err != nil {
|
||||
return Ticket{}, xerrors.Errorf("parse JWS: %w", err)
|
||||
}
|
||||
if len(object.Signatures) != 1 {
|
||||
return Ticket{}, xerrors.New("expected 1 signature")
|
||||
}
|
||||
if object.Signatures[0].Header.Algorithm != string(ticketSigningAlgorithm) {
|
||||
return Ticket{}, xerrors.Errorf("expected ticket signing algorithm to be %q, got %q", ticketSigningAlgorithm, object.Signatures[0].Header.Algorithm)
|
||||
}
|
||||
|
||||
output, err := object.Verify(p.TicketSigningKey)
|
||||
if err != nil {
|
||||
return Ticket{}, xerrors.Errorf("verify JWS: %w", err)
|
||||
}
|
||||
|
||||
var ticket Ticket
|
||||
err = json.Unmarshal(output, &ticket)
|
||||
if err != nil {
|
||||
return Ticket{}, xerrors.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
if ticket.Expiry < time.Now().Unix() {
|
||||
return Ticket{}, xerrors.New("ticket expired")
|
||||
}
|
||||
|
||||
return ticket, nil
|
||||
}
|
Reference in New Issue
Block a user