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 // Sub, when non-empty, mints a subordinate session under the SAME device (DeviceID is the // main device's id) instead of a new device: it shares the device identity (one device in // every list) but holds its own independently-refreshed tokens. cdrop uses "widget" for the // clipboard control / home-screen widget extension (separate OS process), scoped to clipboard // only. Requires a non-empty DeviceID (the main device to attach to). Sub string `json:"sub"` } 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 } // Sub != "" mints a subordinate session under an existing device (DeviceID is the main // device's id), not a new device. It must attach to a concrete device_id (no auto-gen). sub := strings.TrimSpace(req.Sub) if sub != "" && !validSub(sub) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid sub"}) return } deviceID := strings.TrimSpace(req.DeviceID) if deviceID == "" { if sub != "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "sub requires device_id"}) return } 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. A subordinate session instead carries a reduced capability scope // (clipboard) — the widget extension can only read/write the clipboard, not act as the device. tier := "full" if claims.Guest() { tier = "guest" } if sub != "" { tier = "clipboard" } 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, Sub: sub, }) 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() // A subordinate session shares the main device's row — do NOT create a second device row // (it would collide on the device_id PK / surface as a duplicate device). The main device's // row already represents this device in every list; the sub is revoked via the meta cascade. if sub == "" { 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, }) } // validSub accepts a subordinate-session discriminator: a short [a-z0-9_-] token (broker-safe, // control-byte-free). Today cdrop only mints "widget" (clipboard control / home-screen widget); // the format check reserves room for future extension processes without re-validating per value. func validSub(sub string) bool { if len(sub) == 0 || len(sub) > 32 { return false } for _, c := range sub { switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '_', c == '-': default: return false } } return true } // 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 }