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"`