feat(sca): add Strong Customer Authentication surface#558
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
|
@greptile review |
✱ Stainless preview builds for gridThis PR will update the cli go kotlin openapi php python ruby typescript ✅ grid-ruby studio · code
|
Greptile SummaryFORGE THE CHALLENGE; GUARD THE TRANSFER. This PR adds the Strong Customer Authentication API surface for protected money movement. The main changes are:
Confidence Score: 5/5This looks safe to merge.
|
| Filename | Overview |
|---|---|
| openapi/components/schemas/sca/ScaChallenge.yaml | Defines challenge metadata, factor options, expiration, and sequential authorization behavior. |
| openapi/components/schemas/sca/ScaAuthorization.yaml | Defines SMS, TOTP, and passkey authorization proof fields. |
| openapi/paths/quotes/quotes_{quoteId}_execute.yaml | Adds inline authorization and documents the transaction-scoped flow after execution enters pending authorization. |
| openapi/paths/transactions/transactions_{transactionId}_authorize.yaml | Adds transaction challenge authorization with support for sequential challenges. |
Reviews (8): Last reviewed commit: "docs(sca): drop last 'provider' mention ..." | Re-trigger Greptile
|
@greptile review |
|
@greptile review |
1 similar comment
|
@greptile review |
|
@greptile review |
ce99ce4 to
79c4671
Compare
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@greptile review |
1 similar comment
|
@greptile review |
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the SCA surface required for EU (Striga) customers while keeping it
invisible to every other caller:
- New sca/ schemas: ScaFactor, ScaChallenge, ScaAuthorization.
- readOnly scaChallenge on Quote and Transaction, present only while status
is PENDING_AUTHORIZATION (new enum value on TransactionStatus and
Quote.status); omitted entirely for providers that don't require SCA.
- Resource-scoped authorize endpoints: POST /quotes/{quoteId}/authorize
(returns Quote) and POST /transactions/{transactionId}/authorize (returns
Transaction), plus an optional inline scaAuthorization on execute and
transfer-out for one-shot completion.
- Realtime-funding create-quote returns 202 and withholds paymentInstructions
until the challenge is authorized.
Design: webdev docs/plans/2026-06-06-striga-sca-design.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Greptile review: the authorize endpoints are resource-scoped, so the server resolves the active challenge from the quote/transaction context. The id is informational, not a field the client passes back in ScaAuthorization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… only
Address Greptile review: after a quote returns PENDING_AUTHORIZATION, the only
resolution is POST /transactions/{transactionId}/authorize; re-calling execute
409s. scaAuthorization on ExecuteQuoteRequest applies only to the initial call
(to avoid the pending state entirely).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Greptile review: mirror the execute/create-quote treatment so a
transfer-out client has a spec-level signal that the returned transaction may
be PENDING_AUTHORIZATION and require POST /transactions/{transactionId}/authorize.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… resend)
Addresses an independent audit of the SCA surface against Striga's API:
- ScaAuthorization gains `origin` — Striga's wallets/transaction/confirm
requires the WebAuthn origin alongside passkeyAssertion, and the assertion is
origin-bound. Without it a passkey-authorized debit could not be confirmed.
- ScaChallenge surfaces `passkeyAllowedOrigins` (and documents that the relying
party id lives in passkeyAssertionOptions) so the caller knows which origin to
echo back.
- New resend endpoints: POST /quotes/{quoteId}/authorize/resend and
/transactions/{transactionId}/authorize/resend, mapping to Striga's
resend-otp-for-transaction (SMS only; passkey 409). An SMS code that lapses
no longer forces abandoning the challenge.
- Cross-reference the realtime-funding quote authorize path and the resend
endpoint from the execute 200 description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add PENDING_AUTHORIZATION to OutgoingTransactionStatus. SCA-gated debits are outgoing transactions, whose status is overridden by this enum; without the value, generated clients reject the very responses the feature produces. - Add the optional top-level scaFactor field to ExecuteQuoteRequest and TransferOutRequest. The backend already reads it to pick SMS_OTP (default) vs PASSKEY for the per-transaction challenge; it was undiscoverable in the spec. - Clarify where the passkey origin comes from for a per-transaction challenge: such challenges carry passkeyAssertionOptions but omit passkeyAllowedOrigins, so ScaAuthorization.origin and ScaChallenge.passkeyAllowedOrigins now explain the per-transaction case instead of pointing at an absent field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@faraday review this |
|
📌 Bolt Status 2026-07-09 06:51:24 UTC — ⚡ Agent |
…e-target clarity Address faraday review on #558: - Add scaFactor to QuoteRequest so a challenge issued at quote creation (realtime funding) can be SMS_OTP or PASSKEY, matching execute/transfer-out — it was previously unspecified, implying SMS-only. - Reword the execute 200 description, which contradicted itself (claimed the transfer was initiated while also covering the PENDING_AUTHORIZATION case); it now splits the two outcomes and states which authorize endpoint to use. - Document a 429 (RATE_LIMITED) on the authorize and resend endpoints — brute -force protection on the OTP and anti-SMS-spam on resend, mirroring the existing auth/credentials OTP endpoints. - Clarify that resend reuses the existing challenge and does not extend expiresAt (post-expiry recovery tracked as design follow-up F8). - Fix nit: sandbox code wording SMS/TOTP -> SMS (TOTP is invalid per-transaction). Co-Authored-By: Claude <noreply@anthropic.com>
| post: | ||
| summary: Authorize a quote's SCA challenge | ||
| description: | | ||
| Satisfy the Strong Customer Authentication challenge carried by a quote in |
There was a problem hiding this comment.
what's the use case for both a /authorize end point and /execute with SCA object?
There was a problem hiding this comment.
See the paragraph below. Specifically this is needed for realtime-funded quotes, which don't use the execute endpoint. IMO it's good to have these as separate explicit actions for that purpose to avoid confusion.
There was a problem hiding this comment.
damn is real time funding not enough of a secure customer authentication
There was a problem hiding this comment.
Just double checking to make sure that I understand the flow - lets say it's a withdrawal flow like Loot rush of external EURC account > EUR. All managed by Lootrush for their end customers.
In this case since
1. customer adds the beneficiary EUR bank account account
2. move the funds from the customer EURC wallet to fund the quote
3. use the real time funded quote mechanism to convert and offramp
And they need to SCA three times?
There was a problem hiding this comment.
Realtime quotes combine steps 2 and 3 there. Currently that would just require 2 SCAs for the swap and then withdraw. We can get it down to just one though. See this discussion for the "swap and send" flow". https://lightsparkgroup.slack.com/archives/C09H6AEERDF/p1783576868751969
| schema: | ||
| type: string | ||
| example: Transaction:019542f5-b3e7-1d02-0000-000000000005 | ||
| post: |
There was a problem hiding this comment.
When would a user authorize a transaction instead of the quote?
I mean a user calls quotes then execute / authorize with SCA. This is the second SCA?
There was a problem hiding this comment.
This is a good question that's making me think a bit more about whether this one is needed longer term...
Right now, as designed, this one is used in 2 places - non-realtime quotes (triggered with /execute to send from an internal account) and transfer-out calls. The quotes one is only for real-time quotes.
Your question has made me realize that this is a bit confusing though... If we'd already gotten rid of transfer-out, this would be an easy call to just move auth to the quotes call always and start them in PENDING_AUTHORIZATION. However, with transfer-out existing, we still need this no matter what. @shreyav I'm curious your thoughts on the timeline and mechanics here. Should we just skip the transaction auth call and instead follow the same quotes auth flow always? It means transfer-out won't ever work for EU, but maybe that's ok if we're getting rid of it quickly.
There was a problem hiding this comment.
I think thats okay to handle the quotes path only, I plan to make transfers APIs deprecated in the next week, and will add a note about that!
There was a problem hiding this comment.
OK, I did some digging and reminded myself why I have it this way with 2 different quote paths... It's a Striga constraint, not something I explicitly picked. The SCA challenge isn't a standalone object we can mint up front, it's issued by the initiation call (wallets/send/initiate/*) and bound to that specific transfer, and confirm settles that exact challengeId. There's no "give me a challenge without initiating a transfer" primitive.
So it lands wherever initiation happens:
- Realtime quote: quote creation is the initiation, so the challenge's on the quote. Authorize the quote.
- Prefunded quote: the quote's just a price lock on our end, it never touches Striga. Initiation happens at /execute, so the challenge can't exist till then and lands on the transaction.
Putting it on a prefunded quote at creation time would mean initiating a real pending transfer for every quote, including all the ones nobody executes. Quotes are disposable and high-volume, so that's a not ideal.
Unifying on the quotes auth path is still doable though, for prefunded it just means surfacing the execute-time challenge on the quote object and authorizing there. Not super clean though :-/
There was a problem hiding this comment.
Do you want to do execute > quote authorize vs execute > transaction authorize? Also if I'm reading it correctly it looks like ExecuteQuoteRequest has auth evidence in it.
There was a problem hiding this comment.
Do you want to do execute > quote authorize vs execute > transaction authorize?
Yeah, exactly, that's where we'd need to go to make it consistent. Still a bit wonky, but at least it's always on the quote.
Also if I'm reading it correctly it looks like ExecuteQuoteRequest has auth evidence in it.
Ah yes, I left that there as optional to remove one step where possible, but I think that may just add more confusion than it solves. Let me just refactor to unify things a bit real quick and then I'll write out the clear API call steps here for each scenario.
There was a problem hiding this comment.
Refactored to unify, so here are the clear steps. Both scenarios authorize on the quote now; the only difference is where the challenge first surfaces.
Pre-funded (internal-account source, balance already there):
POST /quotes→ quote, no challengePOST /quotes/{quoteId}/execute→ quote returnsPENDING_AUTHORIZATION+scaChallengePOST /quotes/{quoteId}/authorizewith the proof → released
Realtime funding (funds arrive after):
POST /quotes→202, quotePENDING_AUTHORIZATION+scaChallenge,paymentInstructionswithheldPOST /quotes/{quoteId}/authorizewith the proof → instructions populate, then the customer funds
The transaction-scoped authorize endpoint is gone, so there's nothing to reconcile against transactionId anymore.
9d99644 to
19cf8b7
Compare
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the #558 terminology fix: the enrollment / login / trust / reset endpoints now say "customers in a region where SCA is required (e.g. EU)" / "customers outside SCA-regulated regions" instead of "customers whose payment provider requires SCA", and the 409 reads "SCA is not required for this customer". Incidental references to the underlying provider as a system (provider-reported status, risk engine, whitelist handle) are left as-is. Co-Authored-By: Claude <noreply@anthropic.com>
|
19cf8b7 to
8e8e208
Compare
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the #558 terminology fix: the enrollment / login / trust / reset endpoints now say "customers in a region where SCA is required (e.g. EU)" / "customers outside SCA-regulated regions" instead of "customers whose payment provider requires SCA", and the 409 reads "SCA is not required for this customer". Incidental references to the underlying provider as a system (provider-reported status, risk engine, whitelist handle) are left as-is. Co-Authored-By: Claude <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
To the integrator, Grid is the provider; the underlying provider (Striga) must not leak into the API surface. Region-based framing here too. Co-Authored-By: Claude <noreply@anthropic.com>
8e8e208 to
3ab6065
Compare
Stacks on the per-transaction SCA spec (#558) and adds the remaining Strong Customer Authentication surfaces an EU (Striga) partner needs, mirroring the sparkcore handlers' exact routes + JSON shapes. All endpoints are EU-only (409 for providers that don't require SCA). - Factor enrollment: TOTP start/confirm, passkey register start/confirm, list factors, delete passkey. - SCA login / 180-day session: login start/complete, plus record-event. - Beneficiary trust: trust start (issues the SCA challenge + whitelistedId), trust/confirm and untrust/confirm (the whitelisting exemption that lets recurring payees skip per-transaction SCA). - 2FA reset: initiate (201) -> poll status -> complete. Reuses ScaFactor/ScaChallenge/ScaAuthorization from #558. Confirm/login bodies carry the code|passkeyAssertion+origin proof; beneficiary confirm threads the whitelistedId back so confirm never re-whitelists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the #558 terminology fix: the enrollment / login / trust / reset endpoints now say "customers in a region where SCA is required (e.g. EU)" / "customers outside SCA-regulated regions" instead of "customers whose payment provider requires SCA", and the 409 reads "SCA is not required for this customer". Incidental references to the underlying provider as a system (provider-reported status, risk engine, whitelist handle) are left as-is. Co-Authored-By: Claude <noreply@anthropic.com>
…ciary trust, 2FA reset (#600) ## Summary The SCA **management** surface for EU (Striga) customers — the customer-level setup, session, and recovery flows that sit **alongside** per-payment authorization (not inside it). This single PR collapses the original design's **PR3–PR7** (enrollment, passkey, login/session, beneficiary trust, 2FA reset). Stacks on **#558**, which defines the per-transaction authorization core and the shared schemas this PR reuses. All endpoints are **EU-only — `409` for providers that don't require SCA** (the invisibility guarantee) and mirror the sparkcore handlers' exact routes + JSON shapes. Surfaced after an audit of #558 found these existed only as sparkcore temp types, not in the spec. ## Covered by this PR - **Factor enrollment** (design PR3/PR4): `POST /customers/{id}/sca/factors/totp` (+`/confirm`), `.../passkey` (+`/confirm`), `GET .../sca/factors`, `DELETE .../passkey/{credentialId}`. - **Login / 180-day session** (PR5): `POST .../sca/login/start` (+`/complete`); `POST .../sca/record-event`. - **Beneficiary trust** (PR6): `POST .../external-accounts/{extId}/trust` (issues the challenge + `whitelistedId`), `.../trust/confirm`, `.../untrust/confirm` — the whitelisting exemption that lets recurring payees skip per-transaction SCA. - **2FA reset / recovery** (PR7): `POST .../sca/factors/reset` (201) → `GET .../reset/{resetId}` (poll to `LIVENESS_PASSED`) → `POST .../reset/{resetId}/complete`. Reuses `ScaFactor` / `ScaChallenge` / `ScaAuthorization` from #558; confirm/login bodies carry the `code | passkeyAssertion + origin` proof, and beneficiary confirm threads `whitelistedId` back so confirm never re-whitelists (no duplicate SMS). `TwoFactorResetStatus` / `ScaLoginComplete` document their provider-passthrough terminal values (`LIVENESS_PASSED` / `SUCCESS`). ## NOT in this PR - **Per-transaction authorization** — the challenge on a debit, the `authorize` / `resend` endpoints, and inline `scaAuthorization` / `scaFactor` — that's **#558**. - The **sparkcore backend** for these endpoints (separate stack), and client-side WebAuthn ceremony logic (Grid relays opaque passkey blobs; it performs no crypto). ## Known gaps / follow-ups (tracked in the design doc addendum) - **Untrust** has `confirm` but no `start`, and there is **no trust-list read** — a caller who loses `whitelistedId` (or needs the untrust OTP re-sent) is stuck (addendum **F4**). The trusted-beneficiary *exemption benefit* (a trusted payee actually skipping per-tx SCA) is also not yet realized in the backend. - Reset `status` documents `LIVENESS_PASSED` / `PENDING` but no terminal **failure** value — infinite-poll risk (**F10**). - Distinct SCA `409` codes (`SCA_NOT_REQUIRED` vs `NO_PENDING_SCA_CHALLENGE`) are not yet modeled (**F9**). ## Validation `npm run lint:openapi` (redocly + spectral, fail-on-error) passes clean; additions are additive / non-breaking. (The OpenAPI build / breaking-changes / lint CI checks run on the base=`main` PR #558; they do not run here because this PR targets `feat/striga-sca`.) ## Notes - Field shapes mirror the sparkcore implementation (the source of truth until the SDK regenerates). A few Striga response bodies (login-complete, reset-status/complete, passkey list/delete) are typed from the sparkcore `to_dict()` since Striga's docs only cover the start side — flagged for sandbox verification. ## Stacking `main` → #558 (per-transaction) → **#600 (this PR)**. Merges after #558; depends on the schemas #558 introduces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Summary
The per-transaction SCA authorization surface — everything needed to gate and authorize an EU (Striga) money-movement debit with an SMS OTP or passkey. This is the compliance-critical core of the Striga SCA work and the base of the grid-api SCA stack; the management surface (#600) stacks on top, and the sparkcore backend lands separately. Designed to stay invisible to every non-SCA (non-EU) caller.
Covered by this PR
Shared SCA vocabulary (defined here, reused by the whole stack):
ScaFactor(SMS_OTP/TOTP/PASSKEY)ScaChallenge(id,expiresAt,factor,availableFactors,passkeyAssertionOptions,passkeyAllowedOrigins)ScaAuthorization(exactly one ofcode/passkeyAssertion;originrequired with a passkey assertion)Per-transaction authorization:
PENDING_AUTHORIZATIONstatus + readOnlyscaChallengeonQuoteandTransaction(incl. theOutgoingTransactionStatusoverride) — omitted entirely for non-SCA providers.POST /quotes/{id}/authorize→Quote,POST /transactions/{id}/authorize→Transaction(both409for non-SCA providers).POST /quotes/{id}/authorize/resend,POST /transactions/{id}/authorize/resend.scaAuthorizationonexecute/transfer-out(satisfy the challenge in the same call).scaFactoronexecute/transfer-outto pickSMS_OTP(default) orPASSKEYfor the per-transaction challenge.NOT in this PR — see #600 (stacked on top)
The SCA management surface (customer-level setup/recovery, not per-payment authorization) lives in #600 and reuses the schemas defined here:
sca/login/start|completeexternal-accounts/{id}/trust,.../untrust/confirmsca/record-eventAlso out of scope: the sparkcore backend (separate stack) and any client-side WebAuthn ceremony logic (Grid relays opaque passkey blobs; it performs no crypto).
Known caveat
202+ withheld-paymentInstructionspath onPOST /quotesis specified here but not yet reachable — the backend defers it (Striga has no Grid realtime-funding path today; see design doc addendum item F6 / open question 2). It's documented so the contract is stable; treat it as inert until that flow ships.Stacking
main→ #558 (this PR) → #600 (management). #558 is self-contained and independently mergeable — SMS-OTP authorization needs no enrollment or login — so it can merge tomainon its own. #600 depends on theScaFactor/ScaChallenge/ScaAuthorizationschemas introduced here.Invisibility guarantee
scaChallenge,PENDING_AUTHORIZATION, the authorize/resend endpoints, andscaFactorengage only for customers whose provider requires SCA. Non-EU callers see no new fields, no new statuses, and no behavioral change.Validation
npm run lint:openapi(redocly + spectral) passes; additions are additive / non-breaking ("Detect breaking changes" CI green).Design
webdev
docs/plans/2026-06-06-striga-sca-design.md🤖 Generated with Claude Code