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:
@@ -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