File size: 6,984 Bytes
83d6851
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
// Package handler exposes the PUBLIC passkey-ceremony HTTP endpoints that the
// AgentDeck Passkey Helper browser extension calls from the web.whatsapp.com
// origin. These routes are intentionally unauthenticated (no apikey): access is
// gated only by an opaque, short-lived ceremony token minted per pairing.
//
// Contract (tools/passkey-helper/content.js):
//
//	GET  /passkey-ceremony/:token            -> { stage, publicKey?, code?, skipHandoffUX, error? }
//	POST /passkey-ceremony/:token/response   -> body = WebAuthn assertion
//	POST /passkey-ceremony/:token/confirm    -> finish pairing
package handler

import (
	"net/http"

	whatsmeow_service "agentdeck-whatsapp-service/pkg/whatsmeow/service"
	"github.com/gin-gonic/gin"
	"go.mau.fi/whatsmeow/types"
)

// PasskeyHandler wires the public ceremony endpoints to the whatsmeow service.
type PasskeyHandler struct {
	whatsmeowService whatsmeow_service.WhatsmeowService
}

// NewPasskeyHandler builds the handler.
func NewPasskeyHandler(svc whatsmeow_service.WhatsmeowService) *PasskeyHandler {
	return &PasskeyHandler{whatsmeowService: svc}
}

// GetCeremony returns the current ceremony state for a token. The extension
// polls this and drives its UI off the `stage` field.
// @Summary Get passkey ceremony state
// @Description Returns the current WebAuthn passkey-pairing ceremony state for a token. PUBLIC endpoint (no apikey) — access is gated by the opaque short-lived ceremony token. Polled by the AgentDeck Passkey Helper browser extension.
// @Tags Passkey
// @Produce json
// @Param token path string true "Ceremony token"
// @Success 200 {object} gin.H "Ceremony state ({stage, skipHandoffUX, publicKey?, code?, error?})"
// @Failure 400 {object} gin.H "token is required"
// @Failure 404 {object} gin.H "ceremony not found or expired"
// @Failure 503 {object} gin.H "passkey ceremony unavailable"
// @Router /passkey-ceremony/{token} [get]
func (h *PasskeyHandler) GetCeremony(c *gin.Context) {
	token := c.Param("token")
	if token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
		return
	}

	store := h.whatsmeowService.PasskeyCeremonyStore()
	if store == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "passkey ceremony unavailable"})
		return
	}

	_, state, ok := store.Lookup(token)
	if !ok {
		// Unknown/expired token. The extension treats an empty/cleared state
		// after having started as "pairing concluded"; before that it keeps
		// waiting. Return 404 so a never-valid token is distinguishable.
		c.JSON(http.StatusNotFound, gin.H{"error": "ceremony not found or expired"})
		return
	}

	// gin.H keeps keys verbatim (publicKey, skipHandoffUX) — no struct-tag risk.
	resp := gin.H{
		"stage":         state.Stage,
		"skipHandoffUX": state.SkipHandoffUX,
	}
	if len(state.PublicKey) > 0 {
		resp["publicKey"] = state.PublicKey // json.RawMessage — emitted as-is
	}
	if state.Code != "" {
		resp["code"] = state.Code
	}
	if state.Error != "" {
		resp["error"] = state.Error
	}
	c.JSON(http.StatusOK, resp)
}

// SubmitResponse receives the WebAuthn assertion produced by the extension and
// forwards it to WhatsApp via SendPasskeyResponse.
// @Summary Submit passkey WebAuthn response
// @Description Receives the WebAuthn assertion produced by the browser extension and forwards it to WhatsApp. PUBLIC endpoint (no apikey) — gated by the ceremony token. Body is the WebAuthnResponse shape (id, rawId, type, response{clientDataJSON, authenticatorData, signature, userHandle?}), base64url-unpadded.
// @Tags Passkey
// @Accept json
// @Produce json
// @Param token path string true "Ceremony token"
// @Param response body types.WebAuthnResponse true "WebAuthn assertion"
// @Success 200 {object} gin.H "ok"
// @Failure 400 {object} gin.H "token is required / invalid body"
// @Failure 404 {object} gin.H "ceremony not found or expired"
// @Failure 500 {object} gin.H "Internal server error"
// @Failure 503 {object} gin.H "passkey ceremony unavailable"
// @Router /passkey-ceremony/{token}/response [post]
func (h *PasskeyHandler) SubmitResponse(c *gin.Context) {
	token := c.Param("token")
	if token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
		return
	}

	store := h.whatsmeowService.PasskeyCeremonyStore()
	if store == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "passkey ceremony unavailable"})
		return
	}

	instanceID, ok := store.InstanceForToken(token)
	if !ok {
		c.JSON(http.StatusNotFound, gin.H{"error": "ceremony not found or expired"})
		return
	}

	// The extension posts exactly the WebAuthnResponse shape (id, rawId, type,
	// response{clientDataJSON, authenticatorData, signature, userHandle?}),
	// base64url-unpadded, which matches types.WebAuthnResponse's json tags.
	var resp types.WebAuthnResponse
	if err := c.ShouldBindJSON(&resp); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := h.whatsmeowService.SubmitPasskeyResponse(instanceID, &resp); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// Confirm finishes the pairing after the user verified the confirmation code.
// @Summary Confirm passkey pairing
// @Description Finishes the passkey pairing after the user verified the confirmation code. PUBLIC endpoint (no apikey) — gated by the ceremony token.
// @Tags Passkey
// @Produce json
// @Param token path string true "Ceremony token"
// @Success 200 {object} gin.H "ok"
// @Failure 400 {object} gin.H "token is required"
// @Failure 404 {object} gin.H "ceremony not found or expired"
// @Failure 500 {object} gin.H "Internal server error"
// @Failure 503 {object} gin.H "passkey ceremony unavailable"
// @Router /passkey-ceremony/{token}/confirm [post]
func (h *PasskeyHandler) Confirm(c *gin.Context) {
	token := c.Param("token")
	if token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
		return
	}

	store := h.whatsmeowService.PasskeyCeremonyStore()
	if store == nil {
		c.JSON(http.StatusServiceUnavailable, gin.H{"error": "passkey ceremony unavailable"})
		return
	}

	instanceID, ok := store.InstanceForToken(token)
	if !ok {
		c.JSON(http.StatusNotFound, gin.H{"error": "ceremony not found or expired"})
		return
	}

	if err := h.whatsmeowService.ConfirmPasskey(instanceID); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// RegisterRoutes wires the 3 PUBLIC ceremony endpoints directly on the engine,
// with NO auth group (mirrors core.LicenseRoutes). Call from main.go right
// after the license routes.
func RegisterRoutes(eng *gin.Engine, svc whatsmeow_service.WhatsmeowService) {
	h := NewPasskeyHandler(svc)
	grp := eng.Group("/passkey-ceremony")
	{
		grp.GET("/:token", h.GetCeremony)
		grp.POST("/:token/response", h.SubmitResponse)
		grp.POST("/:token/confirm", h.Confirm)
	}
}