package httpapi import ( "crypto/rand" "encoding/base64" "encoding/json" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // shortcutScope is the single scope a shortcut token is granted; the route layer // only lets a token carrying it reach the clipboard endpoints (see server.go). const shortcutScope = "clipboard" // requireScope gates a route group that scoped shortcut tokens may also reach: a // scoped token must carry the named scope, while a full login session always // passes (it has no scope restriction). func requireScope(scope string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, ok := jwtauth.ClaimsFromContext(r.Context()) if !ok { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) return } if claims.Scoped() && !claims.HasScope(scope) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"}) return } next.ServeHTTP(w, r) }) } } // rejectScoped blocks scoped shortcut tokens outright — for every route only a // full login session may touch. A leaked clipboard token thus reaches nothing // here: not the device list, transfers, signalling, nor token self-management. func rejectScoped(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, ok := jwtauth.ClaimsFromContext(r.Context()) if !ok { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) return } if claims.Scoped() { writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"}) return } next.ServeHTTP(w, r) }) } // requireFullSession rejects restricted guest sessions (scan-login borrow): they // may transfer files but not reach account-management surfaces — removing devices, // approving more devices, or minting / listing / revoking long-lived tokens. A // borrowed device thus can't escalate. Full / OIDC / dev sessions pass; scoped // shortcut tokens are already blocked upstream by rejectScoped (AUTH.md §3.2). func requireFullSession(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, ok := jwtauth.ClaimsFromContext(r.Context()) if !ok { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) return } if claims.Guest() { writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"}) return } next.ServeHTTP(w, r) }) } // 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token, // 防止泄漏的快捷指令 token 自我续期或越权。 type shortcutIssueReq struct { Label string `json:"label"` } type shortcutIssueResp struct { // Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。 Token string `json:"token"` Jti string `json:"jti"` Label string `json:"label"` Scopes []string `json:"scopes"` ExpiresAt int64 `json:"expires_at"` } type shortcutTokenView struct { Jti string `json:"jti"` Label string `json:"label"` Scopes []string `json:"scopes"` CreatedAt int64 `json:"created_at"` ExpiresAt int64 `json:"expires_at"` LastUsedAt *int64 `json:"last_used_at"` Revoked bool `json:"revoked"` } // handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores // its metadata, and returns the token string once. Disabled (503) when no HS256 // secret is configured. func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) { if len(s.cfg.HS256Secret) == 0 { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) var req shortcutIssueReq 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 } label := strings.TrimSpace(req.Label) if label == "" { label = "iOS Shortcut" } if len(label) > 64 { label = label[:64] } now := time.Now() active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{ UserID: claims.UserID, ExpiresAt: now.Unix(), }) if err != nil { slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if active >= int64(s.cfg.ShortcutMaxPerUser) { writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"}) return } jti, err := randomTokenID() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) return } expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour) token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires) if err != nil { slog.Error("sign shortcut token failed", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"}) return } if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{ Jti: jti, UserID: claims.UserID, Label: label, Scopes: shortcutScope, CreatedAt: now.Unix(), ExpiresAt: expires.Unix(), }); err != nil { slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } writeJSON(w, http.StatusCreated, shortcutIssueResp{ Token: token, Jti: jti, Label: label, Scopes: []string{shortcutScope}, ExpiresAt: expires.Unix(), }) } // handleShortcutList returns the caller's tokens (metadata only, never the token // string). func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID) if err != nil { slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } out := make([]shortcutTokenView, 0, len(rows)) for _, t := range rows { out = append(out, shortcutTokenView{ Jti: t.Jti, Label: t.Label, Scopes: strings.Fields(t.Scopes), CreatedAt: t.CreatedAt, ExpiresAt: t.ExpiresAt, LastUsedAt: t.LastUsedAt, Revoked: t.Revoked != 0, }) } writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) } // handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so // one user can't revoke another's). verifyHS256 reads the flag on every request, // so revocation takes effect on the token's next use. func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) jti := chi.URLParam(r, "jti") if jti == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"}) return } n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{ Jti: jti, UserID: claims.UserID, }) if err != nil { slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } w.WriteHeader(http.StatusNoContent) } // signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space- // delimited OAuth-style, informational — the store row is authoritative), iat, // exp. The shared HS256 secret is the same one verifyHS256 checks against. func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) { sig, err := jose.NewSigner( jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)}, (&jose.SignerOptions{}).WithType("JWT"), ) if err != nil { return "", err } std := jwt.Claims{ Subject: userID, ID: jti, IssuedAt: jwt.NewNumericDate(iat), Expiry: jwt.NewNumericDate(exp), } return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize() } // randomTokenID returns a 128-bit URL-safe random id for the jti. func randomTokenID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b[:]), nil }