-
Notifications
You must be signed in to change notification settings - Fork 10
feat(sca): add Strong Customer Authentication surface #558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f2d230a
a181371
b72ee9d
736ce4b
0e2f4f1
3603269
1bf0bc3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| type: object | ||
| description: >- | ||
| Optional body for executing a quote. Only needed to supply an inline Strong | ||
| Customer Authentication proof; omit the body entirely for quotes that do not | ||
| require SCA. | ||
| properties: | ||
| scaFactor: | ||
| $ref: ../sca/ScaFactor.yaml | ||
| description: >- | ||
| Optional preferred factor for the Strong Customer Authentication challenge | ||
| this call issues. Only relevant for customers whose provider requires SCA | ||
| (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge | ||
| are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required | ||
| dynamic linking and is rejected here. Omit to default to `SMS_OTP`. | ||
| scaAuthorization: | ||
| $ref: ../sca/ScaAuthorization.yaml | ||
| description: >- | ||
| Optional inline Strong Customer Authentication proof. Only relevant for | ||
| customers whose provider requires SCA (e.g. EU): supply this to satisfy | ||
| the challenge in the same call once the customer has the code/assertion. | ||
| Omit it to receive the quote in `PENDING_AUTHORIZATION` with an | ||
| `scaChallenge`, then authorize separately. Ignored for customers whose | ||
| provider does not require SCA. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| type: object | ||
| description: >- | ||
| Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for | ||
| `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). When supplying a | ||
| `passkeyAssertion`, `origin` is **required** — the WebAuthn assertion is bound | ||
| to the origin it was produced against, and the provider rejects a passkey | ||
| confirmation without it. | ||
| properties: | ||
| code: | ||
| type: | ||
| - string | ||
| - 'null' | ||
| description: >- | ||
| The one-time code the customer received by SMS, or read from their | ||
| authenticator app. In sandbox, the code is always `123456`. | ||
| example: '123456' | ||
| passkeyAssertion: | ||
| type: | ||
| - object | ||
| - 'null' | ||
| additionalProperties: true | ||
| description: >- | ||
| Opaque WebAuthn assertion produced by the device from the challenge's | ||
| `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. | ||
| origin: | ||
| type: | ||
| - string | ||
| - 'null' | ||
| description: >- | ||
| The WebAuthn origin the `passkeyAssertion` was produced against. | ||
| **Required** alongside `passkeyAssertion`; omit it for the `code` path. | ||
| When the challenge lists `passkeyAllowedOrigins` (enrollment / login | ||
| challenges), it must be one of those. A per-transaction passkey challenge | ||
| carries `passkeyAssertionOptions` but may omit `passkeyAllowedOrigins`; in | ||
| that case supply the origin your app invoked the WebAuthn API from, which | ||
| must match the relying party in `passkeyAssertionOptions`. | ||
| example: https://app.example.com |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| type: object | ||
| description: >- | ||
| A Strong Customer Authentication challenge that must be satisfied before a | ||
| money-movement operation can complete. This object is **only present when the | ||
| customer's payment provider requires SCA** (e.g. EU customers on an e-money | ||
| provider); for customers whose provider has no such requirement it is omitted | ||
| entirely and no action is needed. | ||
|
|
||
|
|
||
| When present on a quote or transaction, authorize it by submitting an | ||
| `ScaAuthorization` proof to `POST /quotes/{quoteId}/authorize` or | ||
| `POST /transactions/{transactionId}/authorize`, or inline via the optional | ||
| `scaAuthorization` field on the originating `execute` / `transfer-out` call. | ||
| required: | ||
| - id | ||
| - expiresAt | ||
| - factor | ||
| - availableFactors | ||
| properties: | ||
| id: | ||
| type: string | ||
| description: >- | ||
| Unique identifier for this challenge. The server resolves the active | ||
| challenge from the quote or transaction being authorized, so this field | ||
| need not be supplied back; it is informational (e.g. for logging or | ||
| correlation). | ||
| example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 | ||
|
jklein24 marked this conversation as resolved.
|
||
| expiresAt: | ||
| type: string | ||
| format: date-time | ||
| description: Absolute UTC timestamp after which this challenge can no longer be authorized. | ||
| example: '2025-10-03T12:05:00Z' | ||
| factor: | ||
| $ref: ./ScaFactor.yaml | ||
| description: The factor this challenge was issued for. Defaults to `SMS_OTP`. | ||
| availableFactors: | ||
| type: array | ||
| description: The factors the customer may use to satisfy this challenge. | ||
| items: | ||
| $ref: ./ScaFactor.yaml | ||
| example: | ||
| - SMS_OTP | ||
| passkeyAssertionOptions: | ||
| type: | ||
| - object | ||
| - 'null' | ||
| additionalProperties: true | ||
| description: >- | ||
| Opaque WebAuthn assertion request options (including the relying-party id, | ||
| challenge, and allowed credentials), present only when `factor` is | ||
| `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion | ||
| submitted back in `ScaAuthorization.passkeyAssertion`. | ||
| passkeyAllowedOrigins: | ||
| type: | ||
| - array | ||
| - 'null' | ||
| items: | ||
| type: string | ||
| description: >- | ||
| The origins the WebAuthn ceremony may run against. Populated for | ||
| enrollment and login passkey challenges; the origin the assertion is | ||
| produced against must be one of these and echoed back as | ||
| `ScaAuthorization.origin`. Per-transaction passkey challenges omit this | ||
| (they carry `passkeyAssertionOptions` only) — see `ScaAuthorization.origin` | ||
| for how to source the origin in that case. | ||
| example: | ||
| - https://app.example.com | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| type: string | ||
| enum: | ||
| - SMS_OTP | ||
| - TOTP | ||
| - PASSKEY | ||
| description: | | ||
| A Strong Customer Authentication factor. | ||
|
|
||
| | Factor | Description | | ||
| |--------|-------------| | ||
| | `SMS_OTP` | One-time code sent by SMS to the customer's verified phone. Requires no prior enrollment. | | ||
| | `TOTP` | Time-based one-time code from an authenticator app. Requires enrollment. Not valid for per-transaction challenges (cannot carry dynamic linking). | | ||
| | `PASSKEY` | WebAuthn passkey assertion. Requires enrollment. | |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -115,6 +115,18 @@ post: | |
| exchangeRate: 0.92 | ||
| feesIncluded: 10 | ||
| transactionId: Transaction:019542f5-b3e7-1d02-0000-000000000005 | ||
| '202': | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding a bare 202 to this existing operation is going to silently break already-deployed SDK clients. I checked the generated Python client -
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's fine. I'm not worried about that generated python client. We'll update it before this is actually returned.
jklein24 marked this conversation as resolved.
|
||
| description: > | ||
| Quote created but awaiting Strong Customer Authentication. Returned only | ||
| for customers whose provider requires SCA (e.g. EU) on a realtime-funding | ||
| source: the quote has status `PENDING_AUTHORIZATION` and carries an | ||
| `scaChallenge`, and `paymentInstructions` are **withheld** until the | ||
| challenge is authorized via `POST /quotes/{quoteId}/authorize`. Once | ||
| authorized, the quote's `paymentInstructions` are populated. | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/quotes/Quote.yaml | ||
| '400': | ||
| description: Bad request - Missing or invalid parameters | ||
| content: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| parameters: | ||
| - name: quoteId | ||
| in: path | ||
| description: The unique identifier of the quote whose SCA challenge is being authorized. | ||
| required: true | ||
| schema: | ||
| type: string | ||
| example: Quote:019542f5-b3e7-1d02-0000-000000000006 | ||
| post: | ||
| summary: Authorize a quote's SCA challenge | ||
| description: | | ||
| Satisfy the Strong Customer Authentication challenge carried by a quote in | ||
| `PENDING_AUTHORIZATION` status by submitting an `ScaAuthorization` proof. | ||
|
|
||
| This is used for realtime-funding quotes: the quote is returned with an | ||
| `scaChallenge` and **without** `paymentInstructions`; once authorized, the | ||
| quote advances and its `paymentInstructions` are populated so the customer | ||
| can fund the transfer. | ||
|
|
||
| This endpoint is only meaningful for customers whose payment provider | ||
| requires SCA (e.g. EU customers). For customers whose provider has no such | ||
| requirement, this returns `409`. | ||
|
|
||
| In sandbox, the SMS code is always `123456`. | ||
| operationId: authorizeQuote | ||
| tags: | ||
| - Strong Customer Authentication | ||
| security: | ||
| - BasicAuth: [] | ||
| requestBody: | ||
| required: true | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/sca/ScaAuthorization.yaml | ||
| responses: | ||
| '200': | ||
| description: Challenge authorized; the updated quote is returned. | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/quotes/Quote.yaml | ||
| '400': | ||
| description: Invalid or expired authorization proof | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/errors/Error400.yaml | ||
| '401': | ||
| description: Unauthorized | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/errors/Error401.yaml | ||
| '404': | ||
| description: Quote not found | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/errors/Error404.yaml | ||
| '409': | ||
| description: >- | ||
| The customer's payment provider does not require SCA, or the quote has | ||
| no pending challenge to authorize. | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/errors/Error409.yaml | ||
| '429': | ||
| description: >- | ||
| Too many requests. Returned with `RATE_LIMITED` when authorization | ||
| attempts for this challenge happen too frequently (for example, repeated | ||
| bad codes brute-forcing the OTP). The provider may invalidate the | ||
| challenge after too many failed attempts. Clients should back off and | ||
| retry after the interval indicated by the `Retry-After` response header. | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/errors/Error429.yaml | ||
| '500': | ||
| description: Internal service error | ||
| content: | ||
| application/json: | ||
| schema: | ||
| $ref: ../../components/schemas/errors/Error500.yaml | ||
|
jklein24 marked this conversation as resolved.
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're adding
PENDING_AUTHORIZATIONto a closed-vocabulary enum on endpoints that are already shipped (same issue applies toTransactionStatusandOutgoingTransactionStatus). The generated SDKs model these as closed enums with no unknown-value fallback - e.g. the Python client'sstatus_validate_enumraisesValueErroron anything not in the list. The moment sparkcore emits this status for an SCA-required customer, any integrator still on an older SDK gets a hard validation error on a previously-workingGET /transactions/{id}orGET /quotes/{id}call, and this will likely repeat across every generated language (go/kotlin/ts/rust/flutter). This needs version gating per grid.md §8 rather than extending the existing enum in place.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's fine. Our real SDKs handle new values gracefully.