package web import ( "context" "crypto/rand" "encoding/json" "errors" "net/http" "strconv" "strings" "time" "git.t5de.dev/t5de/t5de-hub/internal/store" "git.t5de.dev/t5de/t5de-hub/internal/token" ) // The proxy signs in like a TV app (OAuth 2.0 device authorization, RFC 8628): it asks for a code, the person // enters it on the website, and the proxy gets tokens: a short-lived access token (Ed25519 JWT) for the API, and // a refresh token it trades for new ones. const ( deviceLifetime = 10 * time.Minute accessLifetime = 15 * time.Minute ) func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func apiError(w http.ResponseWriter, status int, code, description string) { writeJSON(w, status, map[string]string{"error": code, "error_description": description}) } // userCode is what a person types: 8 letters, no look-alikes, shown as XXXX-XXXX. func userCode() string { const alphabet = "BCDFGHJKLMNPQRSTVWXZ" b := make([]byte, 8) rand.Read(b) for i := range b { b[i] = alphabet[int(b[i])%len(alphabet)] } return string(b[:4]) + "-" + string(b[4:]) } func normalizeUserCode(code string) string { code = strings.ToUpper(strings.Map(func(r rune) rune { if r == ' ' || r == '-' { return -1 } return r }, code)) if len(code) == 8 { code = code[:4] + "-" + code[4:] } return code } func (s *Server) deviceCode(w http.ResponseWriter, r *http.Request) { if !s.limiter.allow("device:"+clientIP(r), 20, time.Hour) { apiError(w, http.StatusTooManyRequests, "slow_down", "too many sign-ins from here") return } device := token.Random(32) var code string for range 5 { code = userCode() err := s.Store.CreateDeviceCode(r.Context(), device, code, time.Now().Add(deviceLifetime)) if err == nil { break } if !errors.Is(err, store.ErrTaken) { s.Log.Printf("device code: %v", err) apiError(w, http.StatusInternalServerError, "server_error", "") return } } writeJSON(w, http.StatusOK, map[string]any{ "device_code": device, "user_code": code, "expires_in": int(deviceLifetime.Seconds()), "interval": 3, "verification_uri": s.PublicURL + "/device", "verification_uri_complete": s.PublicURL + "/device?code=" + code, }) } func (s *Server) issue(ctx context.Context, w http.ResponseWriter, personID int64, refresh string) { access, err := s.Keys.Issue("person:"+strconv.FormatInt(personID, 10), Audience, "proxy", accessLifetime) if err != nil { apiError(w, http.StatusInternalServerError, "server_error", "") return } writeJSON(w, http.StatusOK, map[string]any{"access_token": access, "token_type": "Bearer", "expires_in": int(accessLifetime.Seconds()), "refresh_token": refresh}) } func (s *Server) deviceToken(w http.ResponseWriter, r *http.Request) { status, personID, err := s.Store.ConsumeDeviceCode(r.Context(), r.PostFormValue("device_code")) if err != nil { s.Log.Printf("device token: %v", err) apiError(w, http.StatusInternalServerError, "server_error", "") return } switch status { case store.DevicePending: apiError(w, http.StatusBadRequest, "authorization_pending", "waiting for the code to be entered") return case store.DeviceGone: apiError(w, http.StatusBadRequest, "expired_token", "the code expired or was used") return } p, err := s.Store.PersonByID(r.Context(), personID) if err != nil || p.Banned() { apiError(w, http.StatusForbidden, "access_denied", "") return } refresh := token.Random(32) if err := s.Store.CreateRefreshToken(r.Context(), refresh, personID); err != nil { apiError(w, http.StatusInternalServerError, "server_error", "") return } s.issue(r.Context(), w, personID, refresh) } func (s *Server) refresh(w http.ResponseWriter, r *http.Request) { replacement := token.Random(32) personID, err := s.Store.RotateRefreshToken(r.Context(), r.PostFormValue("refresh_token"), replacement) if errors.Is(err, store.ErrNotFound) { apiError(w, http.StatusUnauthorized, "invalid_grant", "signed out") return } if err != nil { apiError(w, http.StatusInternalServerError, "server_error", "") return } p, err := s.Store.PersonByID(r.Context(), personID) if err != nil || p.Banned() { apiError(w, http.StatusForbidden, "access_denied", "") return } s.issue(r.Context(), w, personID, replacement) } // bearer is the person an API request's access token is for. func (s *Server) bearer(r *http.Request) (*store.Person, error) { auth := r.Header.Get("Authorization") tok, ok := strings.CutPrefix(auth, "Bearer ") if !ok { return nil, errors.New("no token") } c, err := s.Keys.Check(tok, Audience) if err != nil { return nil, err } id, err := strconv.ParseInt(strings.TrimPrefix(c.Subject, "person:"), 10, 64) if err != nil { return nil, err } p, err := s.Store.PersonByID(r.Context(), id) if err != nil || p.Banned() { return nil, errors.New("no such person") } return p, nil } func (s *Server) me(w http.ResponseWriter, r *http.Request) { p, err := s.bearer(r) if err != nil { apiError(w, http.StatusUnauthorized, "invalid_token", "") return } links, err := s.Store.Links(r.Context(), p.ID) if err != nil { apiError(w, http.StatusInternalServerError, "server_error", "") return } type link struct { IMVUID int64 `json:"imvu_id"` Username string `json:"username"` Adult bool `json:"adult"` } out := []link{} for _, l := range links { out = append(out, link{l.IMVUID, l.Username, l.Adult()}) } writeJSON(w, http.StatusOK, map[string]any{"username": p.Username, "links": out}) } func (s *Server) realtime(w http.ResponseWriter, r *http.Request) { p, err := s.bearer(r) if err != nil { apiError(w, http.StatusUnauthorized, "invalid_token", "") return } s.Realtime.Serve(w, r, p.ID) } func (s *Server) jwks(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") w.Write(s.Keys.JWKS()) } // LinkOwner implements realtime.Owners. type Owners struct{ Store *store.Store } func (o Owners) LinkOwner(ctx context.Context, imvuID int64) (int64, bool, error) { l, err := o.Store.LinkByIMVUID(ctx, imvuID) if errors.Is(err, store.ErrNotFound) { return 0, false, nil } if err != nil { return 0, false, err } return l.PersonID, true, nil }