From 25d80bc31ddf28a0a09a1698069a056252aa75b5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 3 Jul 2026 04:13:48 +0200 Subject: [PATCH] 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. --- backend/internal/account/email.go | 4 + backend/internal/inttest/email_test.go | 95 ++++++++++++++++++++++++ backend/internal/server/handlers.go | 1 + backend/internal/server/handlers_auth.go | 47 ++++++++++++ 4 files changed, 147 insertions(+) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 23c87b3..ecfc967 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -274,6 +274,10 @@ type LinkConfirmation struct { MergeOwner uuid.UUID } +// IsLogin reports whether the confirmation is a login (the caller mints a session) +// rather than a link (attach the identity, or drive a merge). +func (r LinkConfirmation) IsLogin() bool { return r.Purpose == purposeLogin } + // ConfirmByToken verifies a one-tap deeplink token and performs its purpose. A login // confirms the email identity, clears the guest flag and returns the account to sign // in. A link attaches the confirmed email to the pending account when the address is diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 2e64761..8937057 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -278,3 +278,98 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) { t.Error("confirming the login must clear the guest flag (promote to durable)") } } + +// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/ link +// the branded email carries. +var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`) + +func tokenFromMail(t *testing.T, body string) string { + t.Helper() + m := confirmToken.FindStringSubmatch(body) + if m == nil { + t.Fatalf("no confirm token in mail body %q", body) + } + return m[1] +} + +// TestConfirmByTokenLogin: the one-tap deeplink token completes an email login, +// clearing the guest flag. +func TestConfirmByTokenLogin(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + email := "tok-login-" + uuid.NewString() + "@example.com" + + id, err := svc.RequestLoginCode(ctx, email, "", "en") + if err != nil { + t.Fatalf("request login: %v", err) + } + res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)) + if err != nil { + t.Fatalf("confirm by token: %v", err) + } + if !res.IsLogin() || res.Account != id { + t.Fatalf("login result = %+v, want login for %s", res, id) + } + if !identityConfirmed(t, account.KindEmail, email) { + t.Error("email identity must be confirmed after the token login") + } + if acc, _ := store.GetByID(ctx, id); acc.IsGuest { + t.Error("the token login must clear the guest flag") + } +} + +// TestConfirmByTokenLink: the token attaches a free email to the requesting account. +func TestConfirmByTokenLink(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + acc := provisionAccount(t) + email := "tok-link-" + uuid.NewString() + "@example.com" + + if err := svc.RequestLinkCode(ctx, acc, email); err != nil { + t.Fatalf("request link: %v", err) + } + res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)) + if err != nil { + t.Fatalf("confirm by token: %v", err) + } + if res.IsLogin() || res.NeedsMerge || res.Account != acc { + t.Fatalf("link result = %+v, want a plain link for %s", res, acc) + } + if !identityConfirmed(t, account.KindEmail, email) { + t.Error("email identity must be confirmed after the token link") + } +} + +// TestConfirmByTokenLinkMerge: a token for an address owned by another account signals +// a merge (leaving the token unconsumed for the interactive step). +func TestConfirmByTokenLinkMerge(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + mailer := &capturingMailer{} + svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") + email := "tok-merge-" + uuid.NewString() + "@example.com" + + owner := provisionAccount(t) + if err := svc.RequestCode(ctx, owner, email); err != nil { + t.Fatalf("owner request: %v", err) + } + if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil { + t.Fatalf("owner confirm: %v", err) + } + + other := provisionAccount(t) + if err := svc.RequestLinkCode(ctx, other, email); err != nil { + t.Fatalf("link request: %v", err) + } + res, err := svc.ConfirmByToken(ctx, tokenFromMail(t, mailer.lastBody)) + if err != nil { + t.Fatalf("confirm by token: %v", err) + } + if !res.NeedsMerge || res.MergeOwner != owner { + t.Fatalf("merge result = %+v, want NeedsMerge with owner=%s", res, owner) + } +} diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index b87a2d0..8fd71da 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -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 diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index a9b556c..e13138f 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -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"`