feat: Square 3DS2 SCA primary authorisation for saved-card charges; 2FA demoted to audited backup

SCA is now the PRIMARY authorisation for saved-card (ccof) charges (PSR 2017 /
chargeback liability shift); the homegrown 2FA becomes a BACKUP used only when
SCA is unavailable (e.g. a bank without in-app approval), with a strict audit
trail. The 'approve in your banking app' UX comes from Square buyer
verification. Email/SMS remains the intended 2FA delivery channel; the [2FA]
stdout-log relay (TWO_FACTOR_ALLOW_LOG_DELIVERY=true) is the explicit-insecure
pre-email/SMS stopgap.

BACKEND:
- CreateTerminalPaymentRequest gains VerificationToken (forwarded to Square in
  the admin saved-card branch; validated like the other charge handlers)
- Structured SCA-required error surfacing: isVerificationRequiredError +
  writeVerificationRequiredResponse (HTTP 402 with {code:'verification_required'})
  at all 5 charge error sites — the frontend keys on it to trigger the challenge
- requireTwoFactorForCardAccess reworked: SCA token present => 2FA skipped
  (SCA primary); no token => 2FA fallback requires delivery channel + consume +
  insertTwoFAFallbackAudit (admin_audit_log reason 2fa_fallback_charge,
  {sca_performed:false,...}); TWO_FACTOR_FALLBACK env flag (default true) gates
  the fallback; false => SCA-only posture
- MIT vs CIT: admin till saved-card + admin booking saved-card charges now flag
  customer_initiated=false (merchant-initiated, no SCA, no liability shift);
  customer-initiated online flows keep true

FRONTEND:
- square_card_id threaded through SavedCard/SelectableCard + admin lists
- isVerificationRequiredSignal + shouldFallbackTo2FA helpers (402 + code / text
  fallback); VERIFICATION_REQUIRED_MESSAGE
- tokenizeSavedCardWithVerification (Square SDK tokenize(details, squareCardId))
  with verified/challenge-cancelled/sca-unavailable/sca-failed outcomes
- Per-surface SCA retry with the SAME idempotency key + fresh verification_token
  (booking/tip/till/gift-card/admin); 'waiting for approval in your banking
  app' state on admin surfaces; 2FA backup-only UX in the shared composable

MOCK PARITY:
- SimulateSavedCardVerificationRequired toggle (default off) + grandfathering
- Challenge state (ApprovePendingVerification/DenyPendingVerification,
  ChallengeResult config, token-encoded _ok|_deny outcome)
- One-time-use verify_mock_ token ledger + amount/source binding
- MockCardForm saved-card verification simulation + mock Approve button
- Tests: saved-card SCA gate, one-time-use, denied, amount-mismatch,
  grandfathered; frontend helper tests

DOCS: payments-doc SCA appendix, Technical Manual 2FA section, README,
Overview, Feature Catalog updated to SCA-primary + 2FA-backup; env-var
documented (42/42).

26/26 backend packages; 95/95 frontend tests + build; env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent ecef5da516
commit 5dae0bba08
35 changed files with 3683 additions and 249 deletions
+303 -13
View File
@@ -23,7 +23,8 @@ package square
// FAULT-INJECTION TOGGLES. The mock exposes opt-in toggles (ShouldFail,
// FailRefundCode, ForceCheckoutState, ForceRefundPending, FailCreateCheckout,
// FailAfterCommit, SimulateSourceUsed, ForcePaymentStatus,
// SimulateVerificationRequired) that let dev/tests drive Square failure modes
// SimulateVerificationRequired, SimulateSavedCardVerificationRequired,
// ChallengeResult) that let dev/tests drive Square failure modes
// that are otherwise only reachable against the real API. FailAfterCommit
// simulates the exact "charged but response lost → same-key retry" prod
// scenario: CreatePayment COMMITS the charge (retaining the key and source in
@@ -58,6 +59,7 @@ import (
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -156,10 +158,61 @@ type MockClient struct {
// the charge succeeds. Off by default — existing dev/test flows charge
// plain "cnon:test-card"-style tokens without verification tokens.
SimulateVerificationRequired bool
// SimulateSavedCardVerificationRequired mirrors Square's SCA enforcement on
// saved-card (ccof:) charges — the SCA-primary saved-card posture the
// platform is moving toward (buyer verification on card-on-file charges,
// not just new-card nonces). When true, CreatePayment with a ccof: source
// and NO VerificationToken is rejected with the same structured 400
// CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and a pending
// buyer-verification challenge is recorded for the card. A subsequent
// charge WITH a verification token resolves that challenge (see
// resolveVerificationToken): an explicitly approved challenge, or a
// stateless verify_mock_<prefix>_<amount>_ok token, lets the charge
// succeed; a denied challenge / _deny token is rejected with 400
// VERIFICATION_TOKEN_INVALID. Cards marked via GrandfatherSavedCard bypass
// the gate entirely. Off by default — existing dev/test flows charge
// saved cards without verification tokens, so flipping it on in a
// prod-like test setup intentionally surfaces every ccof charge that would
// be rejected by Square's SCA.
SimulateSavedCardVerificationRequired bool
// ChallengeResult configures the mock's SCA challenge outcome when a
// verification token is supplied on a gated charge. "" or "approve"
// (default) accepts a valid token / approved challenge; "deny" simulates
// the buyer denying EVERY banking-app challenge (any token →
// VERIFICATION_TOKEN_INVALID); "auto" auto-resolves a gate rejection's
// pending challenge as approved, so the next tokenized retry succeeds
// without an explicit ApprovePendingVerification call.
ChallengeResult string
// grandfatheredCards marks ccof: tokens that are exempt from the saved-card
// verification gate (GrandfatherSavedCard). An exempt card charges without
// a verification token even while SimulateSavedCardVerificationRequired is
// on — mirroring cards Square has already verified / stored with a standing
// SCA exemption.
grandfatheredCards map[string]bool
// pendingChallenges records the per-card buyer-verification challenge state
// that the saved-card gate creates when it rejects a ccof charge without a
// token. An opaque (real-Square-shaped) verification token is resolved
// against this ledger; the deterministic verify_mock_... tokens carry their
// own outcome and only consult the ledger to honour an explicit denial.
pendingChallenges map[string]*pendingChallenge
// verifyTokens is a one-time-use ledger of verify_mock_* verification
// tokens consumed by a successful charge or a definitive
// VERIFICATION_TOKEN_INVALID rejection — mirroring real Square, which
// consumes a verification token on use so a replayed token is rejected
// (VERIFICATION_TOKEN_INVALID). Mirrors the usedSources ledger pattern.
verifyTokens map[string]bool
}
type devProdClient struct{}
// pendingChallenge is the recorded buyer-verification challenge state for one
// saved-card (ccof:) token. outcome "" = pending (recorded by the gate's
// rejection, not yet resolved); "approved" / "denied" = resolved via
// ApprovePendingVerification / DenyPendingVerification.
type pendingChallenge struct {
outcome string
}
func (d *devProdClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
return createPaymentHTTP(ctx, req)
}
@@ -251,6 +304,9 @@ func NewDevClient() SquareClient {
customers: make(map[string]*CustomerResult),
completed: make(map[string]*PaymentResult),
usedSources: make(map[string]bool),
grandfatheredCards: make(map[string]bool),
pendingChallenges: make(map[string]*pendingChallenge),
verifyTokens: make(map[string]bool),
}
}
}
@@ -287,6 +343,160 @@ func keyReuseError(key string) error {
}
}
// verificationTokenInvalidError is Square's VERIFICATION_TOKEN_INVALID
// rejection (definitivePaymentCodes): the supplied 3DS/SCA verification token
// is invalid, expired, already used, denied by the buyer, or not bound to this
// card + amount. A definitive payment error — retrying the same request can
// never succeed.
func verificationTokenInvalidError(token, sourceID string) error {
return &squareAPIError{
Code: "VERIFICATION_TOKEN_INVALID",
Category: "PAYMENT_METHOD_ERROR",
Detail: "The verification token is invalid, expired, already used, or not valid for this charge",
StatusCode: http.StatusBadRequest,
err: fmt.Errorf("square: verification token %s is not valid for card-on-file charge on %s", tokenPrefix(token), tokenPrefix(sourceID)),
}
}
// parsedVerifyToken is the deterministic verify_mock_<prefix>_<amount>[_ok|_deny]
// verification-token encoding shared by the dev frontend and the mock, so the
// two sides can exercise approve/deny outcomes WITHOUT shared state.
type parsedVerifyToken struct {
prefix string
amount int64
denied bool
}
// parseVerifyToken parses a deterministic mock verification token of the form
// verify_mock_<prefix>_<amount>[_ok|_deny] (outcome suffix defaults to ok).
// Returns ok=false for anything that is not parseable (an opaque token — the
// same shape as real Square's verification tokens — which resolves against the
// recorded pending challenge instead).
func parseVerifyToken(token string) (parsedVerifyToken, bool) {
const marker = "verify_mock_"
if !strings.HasPrefix(token, marker) {
return parsedVerifyToken{}, false
}
rest := strings.TrimPrefix(token, marker)
if rest == "" {
return parsedVerifyToken{}, false
}
parts := strings.Split(rest, "_")
denied := false
if n := len(parts); n > 1 {
switch parts[n-1] {
case "ok", "deny":
denied = parts[n-1] == "deny"
parts = parts[:n-1]
}
}
if len(parts) == 0 {
return parsedVerifyToken{}, false
}
amount, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return parsedVerifyToken{}, false
}
return parsedVerifyToken{prefix: strings.Join(parts[:len(parts)-1], "_"), amount: amount, denied: denied}, true
}
// verificationTokenPrefixForSource returns the card prefix the mock binds an
// SCA verification token to for a source_id — the SAME derivation the dev
// frontend uses when minting verify_mock_... tokens, so the binding check
// cannot drift between the two sides. New-card (cnon:) nonces bind to the
// first four digits of the PAN the user typed (MockCardForm MOCK_TOKENS);
// saved-card (ccof:) tokens bind to the first four chars after the ccof:
// prefix (MockCardForm.verifySavedCard).
func verificationTokenPrefixForSource(sourceID string) string {
switch sourceID {
case "cnon:test-card":
return "4242"
case "cnon:visa":
return "4111"
case "cnon:mastercard":
return "5555"
case "cnon:amex":
return "3782"
}
if strings.HasPrefix(sourceID, "ccof:") {
rest := strings.TrimPrefix(sourceID, "ccof:")
if len(rest) > 4 {
return rest[:4]
}
return rest
}
return ""
}
// resolveVerificationToken validates a supplied 3DS/SCA verification token for
// a charge. savedCard=true resolves against the saved-card challenge ledger;
// savedCard=false (new-card nonce) treats any present token as satisfying the
// gate. Returns nil when the token is accepted (consuming it in the one-time-use
// ledger), or a definitive 400 VERIFICATION_TOKEN_INVALID. Must be called under
// m.mu (CreatePayment holds the write lock).
func (m *MockClient) resolveVerificationToken(token, sourceID string, amount int64, savedCard bool) error {
isVerifyToken := strings.HasPrefix(token, "verify_mock_")
// ChallengeResult="deny" simulates the buyer denying every banking-app
// challenge: ANY token is definitively rejected.
if m.ChallengeResult == "deny" {
if isVerifyToken {
m.verifyTokens[token] = true
}
return verificationTokenInvalidError(token, sourceID)
}
// One-time-use ledger: a verify_mock_* token is consumed on its first use;
// a second use of the same token is definitively invalid.
if isVerifyToken && m.verifyTokens[token] {
return verificationTokenInvalidError(token, sourceID)
}
// Deterministic tokens carry their own binding and outcome.
if parsed, ok := parseVerifyToken(token); ok {
if parsed.amount != amount || parsed.prefix != verificationTokenPrefixForSource(sourceID) {
m.verifyTokens[token] = true
return verificationTokenInvalidError(token, sourceID)
}
if parsed.denied {
m.verifyTokens[token] = true
return verificationTokenInvalidError(token, sourceID)
}
// Encoded approval. An explicitly DENIED pending challenge is still the
// authority (shared-state denial overrides the stateless encoding).
if savedCard {
if ch := m.pendingChallenges[sourceID]; ch != nil && ch.outcome == "denied" {
m.verifyTokens[token] = true
return verificationTokenInvalidError(token, sourceID)
}
}
m.verifyTokens[token] = true
return nil
}
// Opaque token (real-Square-shaped): a new-card charge accepts it (the cnon
// gate requires only a present token); a saved-card charge resolves it
// against the recorded pending challenge — a token for a challenge that was
// never recorded, or that is still pending, is "never seen" → invalid.
if !savedCard {
if isVerifyToken {
m.verifyTokens[token] = true
}
return nil
}
ch := m.pendingChallenges[sourceID]
if ch == nil || ch.outcome != "approved" {
if isVerifyToken {
m.verifyTokens[token] = true
}
return verificationTokenInvalidError(token, sourceID)
}
if isVerifyToken {
m.verifyTokens[token] = true
}
return nil
}
func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
if m.ShouldFail {
return nil, fmt.Errorf("mock: payment declined (simulated failure)")
@@ -381,20 +591,64 @@ func (m *MockClient) CreatePayment(ctx context.Context, req CreatePaymentReq) (*
}
}
// Mirror Square's SCA enforcement (opt-in toggle, off by default): a
// new-card (cnon:) charge without a 3DS/SCA verification token is rejected
// with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the buyer
// must re-verify and re-tokenize, NOT retry the same request (the code is
// in definitivePaymentCodes). A present verification token (e.g.
// Mirror Square's SCA enforcement on NEW-CARD charges (opt-in toggle, off
// by default): a cnon: charge without a 3DS/SCA verification token is
// rejected with a structured 400 CARD_DECLINED_VERIFICATION_REQUIRED — the
// buyer must re-verify and re-tokenize, NOT retry the same request (the
// code is in definitivePaymentCodes). A present verification token (e.g.
// verify_mock_...) satisfies the gate exactly as production accepts a
// Square-issued verification_token on the CreatePayment body.
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") && req.VerificationToken == "" {
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
if m.SimulateVerificationRequired && strings.HasPrefix(req.SourceID, "cnon:") {
if req.VerificationToken == "" {
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: card requires buyer verification — verification_token required for a new-card (cnon:) charge"),
}
}
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, false); err != nil {
return nil, err
}
}
// Mirror Square's SCA enforcement on SAVED-CARD (ccof:) charges — the
// SCA-primary saved-card posture (opt-in toggle, off by default; placement
// AFTER the customer_id gate above so a ccof charge without a customer is
// still MISSING_REQUIRED_PARAMETER, never verification-required). A ccof:
// charge without a 3DS/SCA verification token is rejected with the same
// structured 400 CARD_DECLINED_VERIFICATION_REQUIRED as the cnon gate, and
// a pending buyer-verification challenge is recorded for the card. A
// subsequent charge WITH a verification token resolves the challenge (see
// resolveVerificationToken). Grandfathered cards (GrandfatherSavedCard)
// bypass the gate.
if m.SimulateSavedCardVerificationRequired && strings.HasPrefix(req.SourceID, "ccof:") {
if req.VerificationToken == "" {
if m.grandfatheredCards[req.SourceID] {
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate bypassed: source %s is grandfathered", tokenPrefix(req.SourceID))
} else {
if m.ChallengeResult == "auto" {
// "auto" config: the banking-app challenge resolves itself
// as approved, so the next tokenized retry succeeds without
// an explicit ApprovePendingVerification call.
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: "approved"}
} else {
m.pendingChallenges[req.SourceID] = &pendingChallenge{outcome: ""}
}
log.Printf("[SQUARE-MOCK] CreatePayment saved-card SCA gate: source %s rejected without a verification token", tokenPrefix(req.SourceID))
return nil, &squareAPIError{
Code: "CARD_DECLINED_VERIFICATION_REQUIRED",
Category: "PAYMENT_METHOD_ERROR",
Detail: "saved card requires buyer verification (3DS/SCA); supply a verification token",
StatusCode: http.StatusBadRequest,
err: errors.New("square: saved card requires buyer verification — verification_token required for a card-on-file (ccof:) charge"),
}
}
} else {
if err := m.resolveVerificationToken(req.VerificationToken, req.SourceID, req.Amount, true); err != nil {
return nil, err
}
}
}
@@ -925,6 +1179,42 @@ func (m *MockClient) UsedSources() []string {
return out
}
// GrandfatherSavedCard marks a ccof: token exempt from the saved-card
// verification gate (SimulateSavedCardVerificationRequired), so that card
// charges without a verification token — mirroring a card Square has already
// verified or holds a standing SCA exemption for.
func (m *MockClient) GrandfatherSavedCard(ccofToken string) {
m.mu.Lock()
defer m.mu.Unlock()
m.grandfatheredCards[ccofToken] = true
}
// ApprovePendingVerification marks the recorded buyer-verification challenge
// for a saved card as approved (creating it if the gate never recorded one), so
// a subsequent tokenized charge of that card succeeds.
func (m *MockClient) ApprovePendingVerification(ccofToken string) {
m.mu.Lock()
defer m.mu.Unlock()
if ch, ok := m.pendingChallenges[ccofToken]; ok {
ch.outcome = "approved"
return
}
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "approved"}
}
// DenyPendingVerification marks the recorded buyer-verification challenge for
// a saved card as denied, so a subsequent tokenized charge of that card is
// rejected with VERIFICATION_TOKEN_INVALID (the token is consumed).
func (m *MockClient) DenyPendingVerification(ccofToken string) {
m.mu.Lock()
defer m.mu.Unlock()
if ch, ok := m.pendingChallenges[ccofToken]; ok {
ch.outcome = "denied"
return
}
m.pendingChallenges[ccofToken] = &pendingChallenge{outcome: "denied"}
}
func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*CardOnFile, error) {
log.Printf("[SQUARE-MOCK] CreateCardOnFile: user=%s", userID)