Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 11
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
f2d230aa181371b72ee9d736ce4b0e2f4f136032691bf0bc337bf5cd338d6043ab6065File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| type: object | ||
| description: >- | ||
| Optional body for executing a quote. Only needed to request a specific Strong | ||
| Customer Authentication factor (`scaFactor`) for the challenge this call | ||
| issues; omit the body entirely otherwise. | ||
| properties: | ||
| scaFactor: | ||
| $ref: ../sca/ScaFactor.yaml | ||
| description: >- | ||
| Optional preferred factor for the Strong Customer Authentication challenge | ||
| this call issues. Only relevant for customers in a region where SCA is required | ||
| (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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -57,6 +57,15 @@ properties: | ||
| example: 'Invoice #1234 payment' | ||
| purposeOfPayment: | ||
| $ref: ./PurposeOfPayment.yaml | ||
| scaFactor: | ||
| $ref: ../sca/ScaFactor.yaml | ||
| description: >- | ||
| Optional preferred factor for a Strong Customer Authentication challenge | ||
| issued at quote creation. Only relevant for a realtime-funding source | ||
| in a region where SCA is required (e.g. EU); ignored otherwise. Valid values are | ||
| `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required | ||
jklein24 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| dynamic linking and is rejected. When the quote is returned in | ||
| `PENDING_AUTHORIZATION`, authorize it via `POST /quotes/{quoteId}/authorize`. | ||
| senderCustomerInfo: | ||
| type: object | ||
| additionalProperties: true | ||
| 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 a passkey confirmation is rejected | ||
| 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,88 @@ | ||
| 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 is in a region where SCA is required** (the EU); for customers outside SCA-regulated regions it is omitted | ||
| entirely and no action is needed. | ||
| When present on a quote, authorize it by submitting an `ScaAuthorization` proof | ||
| to `POST /quotes/{quoteId}/authorize`. | ||
| **A single operation may require more than one authorization, in sequence.** | ||
| Treat `scaChallenge` as *the challenge to satisfy now*, not "the only one". | ||
| After each authorize, re-inspect the returned quote/transaction: if it is | ||
| still `PENDING_AUTHORIZATION`, it carries the **next** `scaChallenge` (a new | ||
| `id`) — authorize that too, and repeat until it leaves `PENDING_AUTHORIZATION`. | ||
| Do not assume one authorization releases the transfer. The number of | ||
| authorizations is flow-dependent and **may decrease in future**: | ||
| for example, a cross-currency send today authorizes the currency conversion | ||
| and the payout as two separate challenges; a future update may | ||
| collapse them into one. A client written to loop on status handles any count | ||
| unchanged. | ||
| 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.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 | ||
| purpose: | ||
| type: | ||
| - string | ||
| - 'null' | ||
| description: >- | ||
| Optional, informational label for what this particular challenge in the | ||
| sequence authorizes — useful for step UX (e.g. "Authorize the currency | ||
| conversion" vs "Authorize the payout"). Known values include | ||
| `CURRENCY_CONVERSION`, `PAYOUT`, and `TRANSFER`, but the set is | ||
| **non-exhaustive and may grow** — treat unrecognized values as a generic | ||
| authorization step and do not branch program logic on it. Omitted when steps are not distinguished (e.g. a single-authorization flow). | ||
| example: PAYOUT | ||
| 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. | |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| type: string | ||
| enum: | ||
| - PENDING | ||
| - PENDING_AUTHORIZATION | ||
| - EXPIRED | ||
| - PROCESSING | ||
| - COMPLETED | ||
| @@ -11,6 +12,7 @@ description: | | ||
| | Status | Description | | ||
| |--------|-------------| | ||
| | `PENDING` | Quote is pending confirmation | | ||
| | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers in a region where SCA is required (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | ||
Contributor 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. do we need this represented in the transaciton as well or can a transaction be pending until the quote is executed? ContributorAuthor 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. IMO it's good to be explicit here that it's a different state that requires some action from the customer Contributor 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. should we also have a corresponding webhook ContributorAuthor 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. I think we get that for free with the existing transaction status changes, right? Contributor 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. ContributorAuthor 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. I'm actually not sure it does since the status never switches to PENDING_AUTHORIZATION, it just starts there when created if needed, so there's no explicit time for a new webhook event here afaict. Contributor 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. hm we do send webhooks for PENDING so it might be nice for consistency just to clarify my understanding
| ||
| | `EXPIRED` | Quote wasn't executed before expiry window | | ||
| | `PROCESSING` | Executing the quote after receiving funds | | ||
| | `COMPLETED` | Payout successfully reached the destination | | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -26,3 +26,11 @@ properties: | ||
| remittanceInformation field, and for wires it populates the OBI | ||
| (Originator to Beneficiary Information) / beneficiary information. | ||
| example: '12345' | ||
| scaFactor: | ||
Contributor 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. I guess we're doing this until we deprecate it for quote? Or should we totally skip it since transfer out will be deprecated soon anyway? ContributorAuthor 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. Short term I think we need it even if things get routed through the quotes flows internally. @shreyav wdyt? Contributor 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. ahh sorry missed this comment! replied on the other thread, I don't have a strong preference if its not too much work, but we do plan to mark this deprecated very soon | ||
| $ref: ../sca/ScaFactor.yaml | ||
| description: >- | ||
| Optional preferred factor for the Strong Customer Authentication challenge | ||
| this call issues. Only relevant for customers in a region where SCA is required | ||
| (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`. | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Uh oh!
There was an error while loading. Please reload this page.
| 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': | ||
jklein24 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. jklein24 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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,87 @@ | ||
| 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 | ||
Contributor 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. what's the use case for both a /authorize end point and /execute with SCA object? ContributorAuthor 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. 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. Contributor 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. damn is real time funding not enough of a secure customer authentication ContributorAuthor 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. EU gonna EU
| ||
| `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. | ||
| As with all SCA, a quote may require more than one authorization: after | ||
| authorizing, if the quote is still `PENDING_AUTHORIZATION` it carries the | ||
| next `scaChallenge` — authorize that too, repeating until it advances (see | ||
| `ScaChallenge`). | ||
| This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, 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: >- | ||
| SCA is not required for this customer, 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 challenge may be invalidated 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.
Uh oh!There was an error while loading. Please reload this page. | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.