-
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
base: main
Are you sure you want to change the base?
Changes from all commits
f2d230a
a181371
b72ee9d
736ce4b
0e2f4f1
3603269
1bf0bc3
37bf5cd
338d604
3ab6065
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,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 |
|---|---|---|
| @@ -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.
|
||
| 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?
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. 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
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. 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.
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. 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
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. Ah ok, if we do that already at creation, then I can add it! Yeah, that sequence looks correct for realtime-funded flows |
||
| | `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?
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. 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.
| 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?
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. 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
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. EU gonna EU
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. 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 And they need to SCA three times?
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. 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 |
||
| `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.