feat(email): one-tap confirm deeplink + client language (PR1b) #162
@@ -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
|
||||
|
||||
@@ -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/<token> 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user