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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user