feat(server): confirm-link endpoint for the one-tap deeplink

Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via
ConfirmByToken and, for a login, mints a session (the deeplink page signs in with
it); for a link, attaches the confirmed email and reports "confirmed" or
"merge_required" (the app drives the interactive merge). The token, not a request
session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests
for the login, link and merge branches (the token is read from the mailed link).
The gateway RPC, live event and SPA route follow.
This commit is contained in:
Ilia Denisov
2026-07-03 04:13:48 +02:00
parent d5b4bba018
commit 25d80bc31d
4 changed files with 147 additions and 0 deletions
+1
View File
@@ -31,6 +31,7 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/email/confirm-link", s.handleEmailConfirmLink)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
// Out-of-app push routing for the platform side-service: the
+47
View File
@@ -212,6 +212,53 @@ func (s *Server) handleEmailLogin(c *gin.Context) {
s.mintSession(c, acc)
}
// confirmLinkResponse is the outcome of a one-tap deeplink confirmation. For a login,
// Session carries the freshly minted credential (the deeplink page signs in with it);
// for a link, Status is "confirmed" or "merge_required" (the app completes the merge).
type confirmLinkResponse struct {
Purpose string `json:"purpose"`
Status string `json:"status"`
Session *sessionResponse `json:"session,omitempty"`
}
// handleEmailConfirmLink verifies a one-tap deeplink token. A login mints a session
// (the deeplink page signs in with it); a link attaches the confirmed email, reporting
// "merge_required" when the address is owned by another account so the app drives the
// interactive merge. The token, not a request session, is the authorization — the page
// need not be signed in.
func (s *Server) handleEmailConfirmLink(c *gin.Context) {
var req tokenRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
abortBadRequest(c, "token is required")
return
}
res, err := s.emails.ConfirmByToken(c.Request.Context(), req.Token)
if err != nil {
s.abortErr(c, err)
return
}
if res.IsLogin() {
acc, err := s.accounts.GetByID(c.Request.Context(), res.Account)
if err != nil {
s.abortErr(c, err)
return
}
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
sess := sessionResponseFor(token, acc)
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "login", Status: "confirmed", Session: &sess})
return
}
status := "confirmed"
if res.NeedsMerge {
status = "merge_required"
}
c.JSON(http.StatusOK, confirmLinkResponse{Purpose: "link", Status: status})
}
// tokenRequest carries an opaque session token.
type tokenRequest struct {
Token string `json:"token"`