package httpapi import ( "encoding/json" "log/slog" "net/http" "strings" "time" "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // 代铸 (proxy-mint). The unified-session-model endpoint that makes every login — browser // global-SSO and native device-authorize alike — a cdrop-managed device session, so all // clients share one device list and one management surface. // // The caller has already been verified at the edge (X-Auth-Subject) by either a global-SSO // cookie (browser) or a device-authorize bootstrap token (desktop/native). cdrop vouches for // that subject and asks the broker (R2: idempotent by user+app+meta) to mint or rotate a // device session bound to the client's stable device_id. Re-logins with the same device_id // rotate the same session instead of piling up — the same trust model as scan-login collect. type deviceSessionReq struct { // DeviceID is the client's stable opaque id (persisted client-side). Empty on first // contact — the server then mints one and returns it for the client to persist. DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` DeviceType string `json:"device_type"` // browser | macos | windows | linux | ios } type deviceSessionResp struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` // UserID + Name + Avatar are the verified identity (X-Auth-Subject / X-Auth-Name / // X-Auth-Avatar). They let a native client — which only ever sees nameless machine // tokens — populate its identity with the real display name and picture instead of the // subject UUID, without a /api/me round-trip. UserID string `json:"user_id"` Name string `json:"name"` Avatar string `json:"avatar,omitempty"` } // handleDeviceSession mints (or idempotently rotates) the caller's cdrop device session. func (s *Server) handleDeviceSession(w http.ResponseWriter, r *http.Request) { // CSRF: a cookie-authenticated browser fetch carries a matching Origin; a cross-site // forgery would not (blocking forged mints that would reintroduce phantom devices). The // desktop's Go-initiated call sends no Origin and is allowed. if !s.sameOrigin(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) var req deviceSessionReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } deviceID := strings.TrimSpace(req.DeviceID) if deviceID == "" { var err error if deviceID, err = newDeviceID(); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } } else if !validDeviceID(deviceID) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid device_id"}) return } deviceName := jwtauth.SanitizeDeviceName(req.DeviceName) if deviceName == "" { deviceName = "New device" } deviceType := qrDeviceType(req.DeviceType) // Mint at the caller's current trust tier: an SSO / device-authorize login is full, a // restricted guest stays guest. This stops a borrowed (guest) browser from minting // itself a full device session. tier := "full" if claims.Guest() { tier = "guest" } accessTTL, refreshTTL := s.tierTTLs(tier) sess, err := s.broker.MintSession(r.Context(), brokerclient.MintParams{ UserID: claims.UserID, Tier: tier, AccessTTL: accessTTL, RefreshTTL: refreshTTL, Sliding: true, Label: deviceName, Meta: deviceID, }) if err != nil { slog.Error("device-session mint failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "mint failed"}) return } now := time.Now().Unix() if err := s.queries.CreateDevice(r.Context(), db.CreateDeviceParams{ DeviceID: deviceID, UserID: claims.UserID, Name: deviceName, Type: deviceType, Tier: tier, BrokerSid: sess.SID, CreatedAt: now, LastSeen: now, }); err != nil { // The session is minted and usable; a failed cache-row write only costs the local // type/presence overlay, so proceed rather than strand the device without tokens. slog.Error("device-session cache write failed", "err", err, "user", claims.UserID, "device", deviceID) } name := claims.Name if name == "" { name = claims.UserID } expiresIn := int(sess.AccessExpires - now) if expiresIn < 0 { expiresIn = 0 } writeJSON(w, http.StatusOK, deviceSessionResp{ AccessToken: sess.Access, RefreshToken: sess.Refresh, ExpiresIn: expiresIn, DeviceID: deviceID, DeviceName: deviceName, UserID: claims.UserID, Name: name, Avatar: claims.Avatar, }) } // validDeviceID accepts a cdrop device_id: the "dev_" prefix plus pure [A-Za-z0-9_-], capped // in length. This both recognises cdrop's own ids (newDeviceID) and guarantees the value is // control-byte-free, so it is safe to pass to the broker as meta (echoed into X-Auth-Meta). func validDeviceID(id string) bool { if !strings.HasPrefix(id, "dev_") || len(id) > 128 { return false } for _, c := range id { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-': default: return false } } return true }