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}