ws_auth.go

  1package api
  2
  3import (
  4	"encoding/json"
  5	"net/http"
  6	"strings"
  7	"sync/atomic"
  8	"time"
  9
 10	"aidanwoods.dev/go-paseto"
 11
 12	"git.kilimanjaro.io/rtw/document"
 13	"git.kilimanjaro.io/rtw/pkg/abac"
 14	"git.kilimanjaro.io/rtw/pkg/id"
 15	"git.kilimanjaro.io/rtw/pkg/log"
 16	"git.kilimanjaro.io/rtw/pkg/session"
 17	"git.kilimanjaro.io/rtw/user"
 18)
 19
 20var privateKey atomic.Pointer[paseto.V4AsymmetricSecretKey]
 21
 22const defaultDoc = "__default__"
 23
 24func init() {
 25	private := paseto.NewV4AsymmetricSecretKey()
 26	privateKey.CompareAndSwap(nil, &private)
 27}
 28
 29type AuthLevel string
 30
 31// Allowable authorization scopes for the y-sweet provider
 32var (
 33	AuthFull     AuthLevel = "full"
 34	AuthReadOnly AuthLevel = "read-only"
 35)
 36
 37// ClientToken is the API contract with @y-sweet/client and @y-sweet/sdk
 38// https://github.com/jamsocket/y-sweet/blob/main/js-pkg/sdk/src/types.ts
 39type ClientToken struct {
 40	URL           string    `json:"url"`
 41	BaseURL       string    `json:"baseUrl"`
 42	DocID         string    `json:"docId"`
 43	Token         string    `json:"token,omitempty"`
 44	Authorization AuthLevel `json:"authorization,omitempty"`
 45}
 46
 47func NewWebsocketAuthHandler(publicURL string, us *user.Service, ds *document.Service) http.Handler {
 48	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 49		l := log.FromContext(r.Context())
 50		docID := r.PathValue("docID")
 51		if docID == "" {
 52			l.Error("expected document ID, got nothing")
 53			http.Error(w, "doc ID is required", http.StatusBadRequest)
 54			return
 55		}
 56
 57		userInfo, ok := us.HasValidSession(r)
 58		if !ok {
 59			l.Error("websocket auth: expected user session, got nothing", "doc_id", docID)
 60			http.Error(w, "user session required", http.StatusBadRequest)
 61			return
 62		}
 63		resourceID, exists, err := ds.DocumentExists(docID)
 64		if err != nil {
 65			l.Error("websocket auth: error getting doc id", "error", err)
 66			http.Error(w, "error getting doc id", http.StatusInternalServerError)
 67			return
 68		}
 69
 70		// create document if this is the first time connecting, or check user's permission level
 71		// for an existing document
 72		var authLevel AuthLevel
 73		if !exists {
 74			l.Debug("doc doesnt exist", "registered", userInfo.Type == session.Registered, "docID", docID)
 75			// first time, create new document or fork from default, assign admin permissions to this user
 76			if userInfo.Type != session.Registered && docID != defaultDoc {
 77				l.Debug("forking default doc")
 78				// anonymous user, fork the default doc with instructions
 79				resourceID, err = ds.ForkDocument(defaultDoc, docID)
 80			} else {
 81				resourceID, err = ds.NewDocument(docID)
 82			}
 83			if err != nil {
 84				l.Error("websocket auth: error inserting new document", "error", err)
 85				http.Error(w, "error inserting new document", http.StatusInternalServerError)
 86				return
 87			}
 88			if err := us.SetDocumentPermission(resourceID, userInfo.ID, abac.Admin); err != nil {
 89				l.Error("websocket auth: error setting document permission", "error", err)
 90				http.Error(w, "error setting document permission", http.StatusInternalServerError)
 91				return
 92			}
 93			authLevel = AuthFull
 94		} else {
 95			// check permissions for an existing document
 96			has, err := us.GetDocumentPermission(resourceID, userInfo.ID)
 97			if err != nil {
 98				l.Error("websocket auth: error getting document permission", "error", err)
 99				http.Error(w, "error getting document permission", http.StatusInternalServerError)
100				return
101			}
102			switch {
103			case abac.Has(has, abac.Write):
104				authLevel = AuthFull
105			case abac.Has(has, abac.Read):
106				authLevel = AuthReadOnly
107			default:
108				l.Info("user attempted to access forbidden resource", "user_id", userInfo.ID, "doc_id", docID)
109				http.Error(w, "no permissions for document", http.StatusForbidden)
110				return
111			}
112		}
113		l.Debug("websocket authorization", "resource_id", resourceID, "user_id", userInfo.ID, "doc_id", docID, "auth", authLevel)
114
115		var wsURL string
116		if strings.HasPrefix(publicURL, "https://") {
117			wsURL = "wss://" + strings.TrimPrefix(publicURL, "https://") + "/ws"
118		} else {
119			wsURL = "ws://" + strings.TrimPrefix(publicURL, "http://") + "/ws"
120		}
121		b, err := json.Marshal(ClientToken{
122			URL:           wsURL,
123			BaseURL:       publicURL,
124			DocID:         docID,
125			Token:         createToken(userInfo.ID, docID),
126			Authorization: authLevel,
127		})
128		if err != nil {
129			http.Error(w, "error marshaling client token", http.StatusInternalServerError)
130			return
131		}
132		w.Header().Add("Content-Type", "application/json")
133		w.WriteHeader(http.StatusOK)
134		if _, err := w.Write(b); err != nil {
135			l.Error("unexpected error writing to response", "service", "websocket.auth", "error", err)
136		}
137	})
138}
139
140func createToken(userID id.Key, docID string) string {
141	token := paseto.NewToken()
142	token.SetExpiration(time.Now().Add(time.Hour * 24 * 30)) // 30 days
143	token.SetSubject(docID)
144	token.SetString("user_id", userID.String())
145	key := privateKey.Load()
146	return token.V4Sign(*key, nil)
147}
148
149// ValidateDocumentToken validates tokens for access to the Y-Sweet proxy. Token must be valid for the
150// specified user and document IDs. UserID is passed as a claim. We do not validate the userID
151// for the token because websocket endpoints do not receive cookie credentials. The claim field `user_id`
152// is trusted as a source of the userID.  Subject must be the docID to pass validation.
153func ValidateDocumentToken(token string, docID string) (*paseto.Token, error) {
154	parser := paseto.NewParser()
155	parser.AddRule(paseto.Subject(docID))
156	parser.AddRule(paseto.NotExpired())
157	key := privateKey.Load()
158	publicKey := key.Public()
159	return parser.ParseV4Public(publicKey, token, nil)
160}