feat(sca): SCA management surface — enrollment, login/session, beneficiary trust, 2FA reset#600
feat(sca): SCA management surface — enrollment, login/session, beneficiary trust, 2FA reset#600jklein24 wants to merge 7 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
✱ Stainless preview builds for gridThis PR will update the cli go kotlin openapi php python ruby typescript Edit this comment to update them. They will appear in their respective SDK's changelogs. ✅ grid-ruby studio · code · diff
✅ grid-openapi studio · code · diff
✅ grid-typescript studio · code · diff
✅ grid-cli studio · code · diff
✅ grid-python studio · code · diff
✅ grid-kotlin studio · code · diff
✅ grid-go studio · code · diff
✅ grid-php studio · code · diff
This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push. |
|
@greptile review |
ce99ce4 to
79c4671
Compare
9c598bf to
8b2b873
Compare
|
@greptile review |
1 similar comment
|
@greptile review |
|
Warning This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
This stack of pull requests is managed by Graphite. Learn more about stacking. |
1bca871 to
9689a50
Compare
9689a50 to
3267da9
Compare
|
@faraday review this |
|
📌 Bolt Status 2026-07-09 06:52:06 UTC — ⚡ Agent |
3267da9 to
513676e
Compare
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
763b88b to
af68ac7
Compare
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
af68ac7 to
d71d44d
Compare
| /customers/{customerId}/external-accounts/{externalAccountId}/trust: | ||
| $ref: paths/customers/customers_{customerId}_external-accounts_{externalAccountId}_trust.yaml | ||
| /customers/{customerId}/external-accounts/{externalAccountId}/trust/confirm: | ||
| $ref: paths/customers/customers_{customerId}_external-accounts_{externalAccountId}_trust_confirm.yaml |
There was a problem hiding this comment.
does untrust need a verify?
There was a problem hiding this comment.
could it be like deleted a trusted account resource?
There was a problem hiding this comment.
or maybe i SCA when adding the external account?
There was a problem hiding this comment.
I think we already have that below?
There was a problem hiding this comment.
Sorry 3 different thoughts
1. If we model trusted external accounts as a resource eg /sca/trusted-external-accounts/{id}
Could untrusting it be a deletion.
- Does the untrust process also need a SCA or can it be a simple delete it?
- Does it make sense to add SCA to external account creation directly? so it becomes create external account + verify sca. Or i guess this SCA is not always required? let me finish reading the SCA thread.
There was a problem hiding this comment.
It does require an SCA to untrust - https://docs.striga.com/reference/confirmbeneficiaryuntrust
9d99644 to
19cf8b7
Compare
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
Per review on #600: - RecordSecurityEventRequest.eventType is now an explicit enum (RESET_PASSWORD_COMPLETED | FAILED_LOGIN_ATTEMPT) with each event's effect, instead of a vague free-string "closed vocabulary". - Drop whitelistedId from the beneficiary-trust surface: BeneficiaryTrustStart returns just the scaChallenge, and confirm/untrust identify the beneficiary by the {externalAccountId} already in the path. Requires the backend to persist the externalAccountId -> whitelist handle mapping (tracked follow-up). Co-Authored-By: Claude <noreply@anthropic.com>
2843a5c to
349f1d0
Compare
19cf8b7 to
8e8e208
Compare
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <noreply@anthropic.com>
Per review on #600: - RecordSecurityEventRequest.eventType is now an explicit enum (RESET_PASSWORD_COMPLETED | FAILED_LOGIN_ATTEMPT) with each event's effect, instead of a vague free-string "closed vocabulary". - Drop whitelistedId from the beneficiary-trust surface: BeneficiaryTrustStart returns just the scaChallenge, and confirm/untrust identify the beneficiary by the {externalAccountId} already in the path. Requires the backend to persist the externalAccountId -> whitelist handle mapping (tracked follow-up). Co-Authored-By: Claude <noreply@anthropic.com>
349f1d0 to
65c265a
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>
Name the provider-reported terminal sentinels (LIVENESS_PASSED for 2FA reset, SUCCESS for login) in the status field descriptions so consumers can write polling/branching logic without out-of-band knowledge, while keeping the field a verbatim provider passthrough rather than a locked enum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mt schemas Address faraday review on #600: - Add an anyOf (code XOR passkeyAssertion+origin) to BeneficiaryTrustConfirmRequest and ScaLoginCompleteRequest so a proof-less body fails schema validation, not just at runtime (matching lint-ignore precedent for required-on-shared-props). - Rename sumsubAccessToken -> livenessAccessToken (TwoFactorResetStart + reset start path) to keep the surface provider-neutral; the sparkcore response key is renamed in lockstep. - Document whitelistedId provenance (scoped to this account's own trust start, not reusable across accounts or after expiry). - Clarify eventType is a provider-defined closed vocabulary, not free-form input. Co-Authored-By: Claude <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>
…nt surface To the integrator, Grid is the provider; the underlying provider must never leak into the docs. Reworded provider-reported status -> the (reported) status, the provider's risk engine -> Grid's risk engine, whitelist/liveness handles and 'when the provider does not return one' -> neutral phrasing. Co-Authored-By: Claude <noreply@anthropic.com>
Per review on #600: - RecordSecurityEventRequest.eventType is now an explicit enum (RESET_PASSWORD_COMPLETED | FAILED_LOGIN_ATTEMPT) with each event's effect, instead of a vague free-string "closed vocabulary". - Drop whitelistedId from the beneficiary-trust surface: BeneficiaryTrustStart returns just the scaChallenge, and confirm/untrust identify the beneficiary by the {externalAccountId} already in the path. Requires the backend to persist the externalAccountId -> whitelist handle mapping (tracked follow-up). Co-Authored-By: Claude <noreply@anthropic.com>
8e8e208 to
3ab6065
Compare
65c265a to
b55685f
Compare
b55685f to
b906634
Compare
There was a problem hiding this comment.
For reset 2FA completion, if it's SMS, this will take in a new mobile number
There was a problem hiding this comment.
Good call. Confirmed it against the docs and added it — complete now takes a mobile object (countryCode + number), required for SMS resets. Docs updated to match.
There was a problem hiding this comment.
Maybe worth enumerating our REJECTED/EXPIRED as terminal so clients don't poll a failed status? our status response also carries factor, enrollmentStatus, expiresAt, completedAt, which are worth surfacing. Also: we rate limit reset initiation (5 per 24h), might be worth adding a 429 for that
There was a problem hiding this comment.
Yep, all in. status is an enum now, with COMPLETED/REJECTED/EXPIRED marked terminal so clients stop polling a dead reset, and it surfaces factor, enrollmentStatus, expiresAt, completedAt. Added a 429 on reset initiate for the 5/24h limit too. Pulled the exact status and enrollmentStatus value sets from your docs.
| Begin trusting (whitelisting) an external account so future sends to it can | ||
| skip the per-transaction SCA ceremony. Returns the `scaChallenge` to satisfy | ||
| (when one is issued). Complete with | ||
| `POST /customers/external-accounts/{externalAccountId}/trust/confirm`. |
There was a problem hiding this comment.
@ronniebhatt please correct me if I'm wrong here but a trusted eur/usdc payout will still get a challenge and what's skipped is the dynamic linking enforcement no?
Also only USDC addresses can be "trusted" right?
There was a problem hiding this comment.
Yeah, fixed the USDC part — docs now say only USDC addresses can be trusted (dropped the old "IBAN or crypto address" framing). On the challenge-vs-dynamic-linking piece I think you're right, but I'd rather have @ronniebhatt confirm the exact semantics before I reword it, since it's his flow. Leaving that half open for him.
There was a problem hiding this comment.
Looks like the lockout state is invisible? HTTP 423 (code 31105). We return {event, suspended?, lockedUntil?, failedAttempts?} so might be worth surfacing lockedUntil to tell the user to try again in XX
There was a problem hiding this comment.
Good catch, the lockout state was totally invisible. Fixed — record-event returns { suspended, lockedUntil, failedAttempts } now (200 instead of 204), plus a 423 (ACCOUNT_LOCKED) that surfaces lockedUntil/failedAttempts so integrators can tell the user when to retry. Added the same 423 to login-complete since a locked user hits it there too.
| Begin enrolling a WebAuthn passkey factor for the customer. Returns opaque | ||
| WebAuthn registration `options`; pass them to the device's WebAuthn API and | ||
| submit the resulting credential via |
There was a problem hiding this comment.
Please remember our current implementation is one passkey per user (We return error 100002 when a user tries to enrol a new passkey)
There was a problem hiding this comment.
Noted and documented. Enrolling a passkey now returns a 409 (PASSKEY_ALREADY_ENROLLED) on a second one, and the docs call out the one-per-user limit plus the fact that the passkey side of the factor list is 0-or-1.
Untrust previously had only a confirm step, so an SMS_OTP (default, no
enrollment) or PASSKEY customer had no way to obtain the SCA challenge that
untrust/confirm expects — only a self-served TOTP code worked. Add
POST /customers/{customerId}/external-accounts/{externalAccountId}/untrust
(startBeneficiaryUntrust), mirroring startBeneficiaryTrust: it issues the
scaChallenge that untrust/confirm satisfies. Returns BeneficiaryTrustStart.
b906634 to
94e5d2f
Compare

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 —
409for 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
POST /customers/{id}/sca/factors/totp(+/confirm),.../passkey(+/confirm),GET .../sca/factors,DELETE .../passkey/{credentialId}.POST .../sca/login/start(+/complete);POST .../sca/record-event.POST .../external-accounts/{extId}/trust(issues the challenge +whitelistedId),.../trust/confirm,.../untrust/confirm— the whitelisting exemption that lets recurring payees skip per-transaction SCA.POST .../sca/factors/reset(201) →GET .../reset/{resetId}(poll toLIVENESS_PASSED) →POST .../reset/{resetId}/complete.Reuses
ScaFactor/ScaChallenge/ScaAuthorizationfrom #558; confirm/login bodies carry thecode | passkeyAssertion + originproof, and beneficiary confirm threadswhitelistedIdback so confirm never re-whitelists (no duplicate SMS).TwoFactorResetStatus/ScaLoginCompletedocument their provider-passthrough terminal values (LIVENESS_PASSED/SUCCESS).NOT in this PR
authorize/resendendpoints, and inlinescaAuthorization/scaFactor— that's feat(sca): add Strong Customer Authentication surface #558.Known gaps / follow-ups (tracked in the design doc addendum)
confirmbut nostart, and there is no trust-list read — a caller who loseswhitelistedId(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.statusdocumentsLIVENESS_PASSED/PENDINGbut no terminal failure value — infinite-poll risk (F10).409codes (SCA_NOT_REQUIREDvsNO_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=mainPR #558; they do not run here because this PR targetsfeat/striga-sca.)Notes
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