diff --git a/.github/workflows/test-verification-integrations.yml b/.github/workflows/test-verification-integrations.yml new file mode 100644 index 0000000..dbe2ba0 --- /dev/null +++ b/.github/workflows/test-verification-integrations.yml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +name: Test verification integrations +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + regression: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install handler dependencies + run: | + npm ci --ignore-scripts --prefix identity-verification/prove/webhook + npm ci --ignore-scripts --prefix identity-verification/persona/webhook + npm ci --ignore-scripts --prefix identity-verification/socure/webhook + npm ci --ignore-scripts --prefix fraud-bot-protection/recaptcha/webhook + - name: Run isolated regressions + run: node --test scripts/test-verification-integrations.cjs diff --git a/email-providers/brevo/README.md b/email-providers/brevo/README.md index 1e885f9..72dd0b0 100644 --- a/email-providers/brevo/README.md +++ b/email-providers/brevo/README.md @@ -20,7 +20,7 @@ Ory Kratos courier → Brevo SMTP relay (smtp-relay.brevo.com:587) → recipient 1. **Ory Network account.** 2. **Brevo account** at [brevo.com](https://www.brevo.com/). 3. A **verified sender domain** in Brevo (DKIM + Return-Path). Unauthenticated senders will be filtered or rejected by major providers. -4. An **SMTP key** generated from **Brevo → SMTP & API → SMTP** — note the SMTP login (your Brevo account email) and the SMTP key. +4. An **SMTP key** generated from **Brevo → SMTP & API → SMTP** — note the SMTP login and the SMTP key shown on that page. URI-encode both in the connection URI (for example, `@` becomes `%40`). ## Configuration @@ -32,32 +32,34 @@ In the Ory Console under **Email & SMS → Email** or via CLI: ory patch identity-config \ --project \ --workspace \ - --replace '/courier/smtp/connection_uri="smtps://:@smtp-relay.brevo.com:587"' \ + --replace '/courier/smtp/connection_uri="smtp://:@smtp-relay.brevo.com:587/"' \ --replace '/courier/smtp/from_address="no-reply@your-verified-domain.com"' \ --replace '/courier/smtp/from_name="Your App"' ``` +Use `smtp://` with port `587` (STARTTLS) or `smtps://` with port `465` (implicit TLS). `smtps://` on port `587` fails the TLS handshake. + ### REST API (alternative) -If you need Brevo-side analytics that the SMTP relay does not surface, you can post to Brevo's REST API instead via Kratos's HTTP courier: +If you need Brevo-side analytics that the SMTP relay does not surface, you can post to Brevo's REST API instead via Kratos's HTTP email courier (`courier.delivery_strategy: http`). `courier.channels` only accepts the `sms` channel, so email over HTTP is configured under `courier.http`: ```bash ory patch identity-config \ --project \ --workspace \ - --replace '/courier/channels=[{ - "id": "email", - "type": "http", - "request_config": { - "url": "https://api.brevo.com/v3/smtp/email", - "method": "POST", - "headers": { - "api-key": "", - "content-type": "application/json" - }, - "body": "base64://'"$(base64 < brevo-body.jsonnet)"'" - } - }]' + --replace '/courier/delivery_strategy="http"' \ + --replace '/courier/http/request_config={ + "url": "https://api.brevo.com/v3/smtp/email", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "auth": { + "type": "api_key", + "config": { "name": "api-key", "value": "", "in": "header" } + }, + "body": "base64://'"$(base64 < brevo-body.jsonnet | tr -d '\n')"'" + }' ``` `brevo-body.jsonnet`: @@ -67,11 +69,16 @@ function(ctx) { sender: { email: 'no-reply@your-verified-domain.com', name: 'Your App' }, to: [{ email: ctx.recipient }], subject: ctx.subject, - htmlContent: ctx.body.html, - textContent: ctx.body.plaintext, -} + textContent: ctx.body, +} + ( + if std.objectHas(ctx, 'html_body') && ctx.html_body != '' + then { htmlContent: ctx.html_body } + else {} +) ``` +The courier passes the rendered plaintext body as `ctx.body` and the rendered HTML body, when the template has one, as `ctx.html_body`. See the [`ctx` reference](https://www.ory.com/docs/kratos/emails-sms/sending-emails-smtp#payload). + ## Technical details | Field | Value | diff --git a/email-providers/brevo/registry.entry.yaml b/email-providers/brevo/registry.entry.yaml index 9b8108d..596b00c 100644 --- a/email-providers/brevo/registry.entry.yaml +++ b/email-providers/brevo/registry.entry.yaml @@ -15,8 +15,8 @@ useCase: >- An Ory Network customer with EU data-residency or GDPR concerns wants a EU-headquartered email vendor for identity emails, in addition to or instead of US-based providers like SendGrid or Mailgun. coreFunctionality: |- - - SMTP relay (recommended): smtps://:@smtp-relay.brevo.com:587 (STARTTLS) or :465 (TLS). - - REST API alternative: POST https://api.brevo.com/v3/smtp/email with header `api-key: ` and a Jsonnet body wired via Kratos's HTTP courier. + - SMTP relay (recommended): smtp://:@smtp-relay.brevo.com:587 (STARTTLS) or smtps://…@smtp-relay.brevo.com:465 (implicit TLS). + - REST API alternative: POST https://api.brevo.com/v3/smtp/email with header `api-key: ` and a Jsonnet body wired via Kratos's HTTP email courier (courier.delivery_strategy: http). - Brevo SMTP keys are distinct from API keys — do not confuse them. - Free tier rate-limits SMTP; identity flows at scale should be on a paid plan. - DKIM and Return-Path must be aligned with the From domain for Gmail/Microsoft deliverability. diff --git a/email-providers/sparkpost/README.md b/email-providers/sparkpost/README.md index 0145378..c644487 100644 --- a/email-providers/sparkpost/README.md +++ b/email-providers/sparkpost/README.md @@ -30,34 +30,38 @@ Ory Kratos courier → SparkPost SMTP (smtp.sparkpostmail.com:587) → recipient ory patch identity-config \ --project \ --workspace \ - --replace '/courier/smtp/connection_uri="smtps://SMTP_Injection:@smtp.sparkpostmail.com:587"' \ + --replace '/courier/smtp/connection_uri="smtp://SMTP_Injection:@smtp.sparkpostmail.com:587/"' \ --replace '/courier/smtp/from_address="no-reply@your-verified-domain.com"' \ --replace '/courier/smtp/from_name="Your App"' ``` The literal username `SMTP_Injection` is correct — SparkPost uses it as the SMTP login for all customers. The password is your API key. -For the EU stack, replace the host with `smtp.eu.sparkpost.com`. +SparkPost uses STARTTLS on port `587`, so the scheme is `smtp://`. `smtps://` on port `587` fails the TLS handshake. + +For the EU stack, replace the host with `smtp.eu.sparkpostmail.com`. ### REST API (alternative) +The REST API goes through Kratos's HTTP email courier (`courier.delivery_strategy: http`). `courier.channels` only accepts the `sms` channel, so email over HTTP is configured under `courier.http`: + ```bash ory patch identity-config \ --project \ --workspace \ - --replace '/courier/channels=[{ - "id": "email", - "type": "http", - "request_config": { - "url": "https://api.sparkpost.com/api/v1/transmissions", - "method": "POST", - "headers": { - "authorization": "", - "content-type": "application/json" - }, - "body": "base64://'"$(base64 < sparkpost-body.jsonnet)"'" - } - }]' + --replace '/courier/delivery_strategy="http"' \ + --replace '/courier/http/request_config={ + "url": "https://api.sparkpost.com/api/v1/transmissions", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "auth": { + "type": "api_key", + "config": { "name": "Authorization", "value": "", "in": "header" } + }, + "body": "base64://'"$(base64 < sparkpost-body.jsonnet | tr -d '\n')"'" + }' ``` `sparkpost-body.jsonnet`: @@ -68,12 +72,17 @@ function(ctx) { content: { from: { email: 'no-reply@your-verified-domain.com', name: 'Your App' }, subject: ctx.subject, - html: ctx.body.html, - text: ctx.body.plaintext, - }, + text: ctx.body, + } + ( + if std.objectHas(ctx, 'html_body') && ctx.html_body != '' + then { html: ctx.html_body } + else {} + ), } ``` +The courier passes the rendered plaintext body as `ctx.body` and the rendered HTML body, when the template has one, as `ctx.html_body`. See the [`ctx` reference](https://www.ory.com/docs/kratos/emails-sms/sending-emails-smtp#payload). + For the EU stack, replace the API base with `https://api.eu.sparkpost.com`. ## Technical details @@ -81,7 +90,7 @@ For the EU stack, replace the API base with `https://api.eu.sparkpost.com`. | Field | Value | |---|---| | SMTP host (US) | `smtp.sparkpostmail.com` | -| SMTP host (EU) | `smtp.eu.sparkpost.com` | +| SMTP host (EU) | `smtp.eu.sparkpostmail.com` | | SMTP port | `587` (STARTTLS) | | SMTP login | literal string `SMTP_Injection` | | SMTP password | your SparkPost API key | diff --git a/email-providers/sparkpost/registry.entry.yaml b/email-providers/sparkpost/registry.entry.yaml index 0256ec4..0b1315e 100644 --- a/email-providers/sparkpost/registry.entry.yaml +++ b/email-providers/sparkpost/registry.entry.yaml @@ -15,8 +15,8 @@ useCase: >- An Ory Network customer with high transactional email volume and stringent deliverability requirements wants enterprise-grade tooling and per-region (US vs EU) hosting for data-residency. coreFunctionality: |- - - SMTP relay (recommended): smtps://SMTP_Injection:@smtp.sparkpostmail.com:587 (US) or smtp.eu.sparkpost.com (EU). The literal username "SMTP_Injection" is correct for all customers; password is the API key. - - REST Transmissions API alternative: POST https://api.sparkpost.com/api/v1/transmissions (US) or api.eu.sparkpost.com (EU) with `Authorization: ` and a Jsonnet body. + - SMTP relay (recommended): smtp://SMTP_Injection:@smtp.sparkpostmail.com:587 (US, STARTTLS) or smtp.eu.sparkpostmail.com (EU). The literal username "SMTP_Injection" is correct for all customers; password is the API key. + - REST Transmissions API alternative: POST https://api.sparkpost.com/api/v1/transmissions (US) or api.eu.sparkpost.com (EU) with `Authorization: ` and a Jsonnet body, via Kratos's HTTP email courier (courier.delivery_strategy: http). - SparkPost separates US and EU accounts — pick data residency before integration; migrations are not trivial. - API key needs the "Send via SMTP" permission; minimum scope is sufficient. - Stored templates available via the API if SparkPost-side template management is preferred over Kratos-side rendering (not recommended — Ory renders templates by default). diff --git a/fraud-bot-protection/castle/README.md b/fraud-bot-protection/castle/README.md index adfaedd..83cf81d 100644 --- a/fraud-bot-protection/castle/README.md +++ b/fraud-bot-protection/castle/README.md @@ -9,15 +9,16 @@ Castle.io is an adaptive risk-scoring platform that evaluates device fingerprint ## Use case -A B2C product wants to keep credential-stuffing and account-takeover attempts out of its login flow without blocking legitimate users behind a CAPTCHA. Castle scores each login in real time; the integration applies an `allow` / `challenge` / `deny` decision on the `after` trigger, before the session is issued, with challenge events flagged for MFA step-up in the client. +A B2C product wants to keep credential-stuffing and account-takeover attempts out of its login flow without blocking legitimate users behind a CAPTCHA. Castle scores each login in real time; the integration applies an `allow` / `challenge` / `deny` decision on the `after` trigger, before the session is issued, and requires a second factor for challenge events. ## How it works 1. The browser collects a Castle device fingerprint (`castle.js` SDK) and submits it via Ory's `transient_payload.castle_request_token` on the login or registration flow. -2. Ory authenticates the request, then fires the post-login (or post-registration) Action webhook to this handler. -3. The handler verifies the shared secret, builds a Castle Risk API request from the identity and request context, and calls Castle. +2. Ory authenticates the request, then calls this handler from the login action (sync, `response.parse: true`) or the registration action (after the identity is saved, `response.ignore: true`). +3. The handler verifies the shared secret, builds a Castle Risk API request with the Ory identity ID as `user.id`, the client IP from the `True-Client-Ip` header, and the request headers, and calls Castle. 4. Castle returns a risk score (0.0–1.0) and a policy action (`allow` / `challenge` / `deny`). -5. The handler translates the response into Ory's response shape: deny → `400` with a flow message; challenge → `200` with `metadata_public.requires_mfa_stepup=true`; allow → `200` with the score recorded in metadata. Castle outages fall open (`200`, empty body) so authentication is never blocked by Castle availability. +5. At login, the handler responds: deny → `403` with a `messages` body, which blocks the sign-in and shows the text; challenge → `200` with `{"required_aal": "aal2"}`, which makes the user complete a second factor when the `webhook_response_directives` feature flag is on and is ignored otherwise; allow → `200` with `{}`. Ory doesn't save identity changes that a login action returns. Castle outages fall open (`200`, empty body) so authentication is never blocked by Castle availability. +6. At registration, the account already exists. The handler reports the event to Castle and, on `deny`, deactivates the identity through the Ory Admin API when `ORY_SDK_URL` and `ORY_ADMIN_API_KEY` are set. ## Prerequisites @@ -44,18 +45,20 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. In the Ory Console, configure two Action hooks using the snippets in [`ory-actions.yaml`](ory-actions.yaml) (post-login and post-registration). -2. The body templates are [`jsonnet/login.jsonnet`](jsonnet/login.jsonnet) and [`jsonnet/registration.jsonnet`](jsonnet/registration.jsonnet). +1. Configure two actions using the snippets in [`ory-actions.yaml`](ory-actions.yaml) (login and registration). Put them in the method-specific lists, such as `selfservice.flows.login.after.password.hooks`, and repeat them for every method you enable, such as `oidc` or `code`. Ory runs the global `after.hooks` list only for a method that has no hooks of its own. Keep the `session` hook last in the registration list. +2. The body templates are [`jsonnet/login.jsonnet`](jsonnet/login.jsonnet) and [`jsonnet/registration.jsonnet`](jsonnet/registration.jsonnet). `body: file://./jsonnet/...` works for self-hosted Ory Kratos. On Ory Network, encode each template with `base64 < jsonnet/login.jsonnet | tr -d '\n'` and set `body: base64://`. 3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the hook config. 4. Embed `castle.js` in your login/registration UI and pass the device token via `transient_payload.castle_request_token`. See the [docs page](https://ory.com/docs/integrates-with/fraud-bot-protection/castle) for client-side examples. +5. Optionally set `ORY_SDK_URL` and `ORY_ADMIN_API_KEY` so the registration endpoint can deactivate identities that Castle denies. + Detailed setup with screenshots, threshold tuning guidance, and Castle dashboard wiring: see the docs page. ## Troubleshooting - **`401 invalid webhook secret`** — `ORY_WEBHOOK_SECRET` in `.env` doesn't match the `X-Webhook-Secret` value in the Ory hook config. - **Logins always allow regardless of risk** — Castle is throwing and the handler is failing open. Check handler logs for `Castle API ...` errors and verify `CASTLE_API_SECRET` is correct. -- **`400 Login blocked`** for low-risk users — `CASTLE_DENY_THRESHOLD` is too aggressive, or your Castle policy is returning `deny` for routine traffic. Review the policy in the Castle dashboard. +- **"Sign-in blocked"** for low-risk users — `CASTLE_DENY_THRESHOLD` is too aggressive, or your Castle policy is returning `deny` for routine traffic. Review the policy in the Castle dashboard. - **Risk score shows as `0` for every event** — `castle.js` isn't loaded or `transient_payload.castle_request_token` isn't being set. Without the device token, Castle scores on IP and headers only. ## License diff --git a/fraud-bot-protection/castle/jsonnet/login.jsonnet b/fraud-bot-protection/castle/jsonnet/login.jsonnet index e713649..6f0f2a7 100644 --- a/fraud-bot-protection/castle/jsonnet/login.jsonnet +++ b/fraud-bot-protection/castle/jsonnet/login.jsonnet @@ -1,17 +1,15 @@ -// Body for the post-login Ory Action that calls the Castle webhook handler. -// The handler enriches this and forwards to Castle's Risk API. -function(ctx) { - identity: { - id: ctx.identity.id, - traits: ctx.identity.traits, - metadata_public: ctx.identity.metadata_public, - created_at: ctx.identity.created_at, - }, - flow: { - id: ctx.flow.id, - type: ctx.flow.type, - transient_payload: if std.objectHas(ctx.flow, "transient_payload") then ctx.flow.transient_payload else {}, - }, - request_headers: ctx.request_headers, - request_url: ctx.request_url, -} +// Body for the login.after action that calls the Castle handler. The client IP +// comes from the True-Client-Ip header, and the device token from the flow's +// transient_payload. +function(ctx) + local payload = std.get(ctx.flow, 'transient_payload', null); + { + request_token: if payload == null then null else std.get(payload, 'castle_request_token', null), + user: { + id: ctx.identity.id, + email: std.get(ctx.identity.traits, 'email', null), + registered_at: std.get(ctx.identity, 'created_at', null), + }, + ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], + headers: ctx.request_headers, + } diff --git a/fraud-bot-protection/castle/jsonnet/registration.jsonnet b/fraud-bot-protection/castle/jsonnet/registration.jsonnet index d016ce0..b4f7b32 100644 --- a/fraud-bot-protection/castle/jsonnet/registration.jsonnet +++ b/fraud-bot-protection/castle/jsonnet/registration.jsonnet @@ -1,17 +1,15 @@ -// Body for the post-registration Ory Action that calls the Castle webhook handler. -// Same payload shape as login — Castle distinguishes events by handler endpoint. -function(ctx) { - identity: { - id: ctx.identity.id, - traits: ctx.identity.traits, - metadata_admin: ctx.identity.metadata_admin, - created_at: ctx.identity.created_at, - }, - flow: { - id: ctx.flow.id, - type: ctx.flow.type, - transient_payload: if std.objectHas(ctx.flow, "transient_payload") then ctx.flow.transient_payload else {}, - }, - request_headers: ctx.request_headers, - request_url: ctx.request_url, -} +// Body for the registration.after action that calls the Castle handler. The client IP +// comes from the True-Client-Ip header, and the device token from the flow's +// transient_payload. +function(ctx) + local payload = std.get(ctx.flow, 'transient_payload', null); + { + request_token: if payload == null then null else std.get(payload, 'castle_request_token', null), + user: { + id: ctx.identity.id, + email: std.get(ctx.identity.traits, 'email', null), + registered_at: std.get(ctx.identity, 'created_at', null), + }, + ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], + headers: ctx.request_headers, + } diff --git a/fraud-bot-protection/castle/ory-actions.yaml b/fraud-bot-protection/castle/ory-actions.yaml index 1c418b9..ffcc7c1 100644 --- a/fraud-bot-protection/castle/ory-actions.yaml +++ b/fraud-bot-protection/castle/ory-actions.yaml @@ -1,48 +1,65 @@ # Castle.io risk assessment hooks. # -# Both hooks run synchronously (response.parse: true, can_interrupt: true) so -# Castle can block high-risk events. The handler returns 400 with a flow -# message to deny, or 200 with metadata_public.requires_mfa_stepup=true to -# trigger MFA step-up in the client. +# The login action runs synchronously (response.parse: true) so the handler +# can block the sign-in or require a second factor. The registration action +# runs after the identity is saved (no response.parse) so Castle receives the +# real identity ID; it reports the event and can't block the registration. # -# The client-side Castle.js SDK should pass the device fingerprint via -# transient_payload.castle_request_token. +# The password lists are aliased into the oidc, code, passkey, and webauthn +# lists, so every sign-in method runs the same hooks. Alias them into any +# other method you enable, such as saml, the same way. The client-side Castle +# SDK passes the request token via transient_payload.castle_request_token. selfservice: flows: login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/castle/login - method: POST - body: file://./jsonnet/login.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/castle/login + method: POST + body: file://./jsonnet/login.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/castle/registration - method: POST - body: file://./jsonnet/registration.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/castle/registration + method: POST + body: file://./jsonnet/registration.jsonnet + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks diff --git a/fraud-bot-protection/castle/registry.entry.yaml b/fraud-bot-protection/castle/registry.entry.yaml index 202de5d..fb78ac0 100644 --- a/fraud-bot-protection/castle/registry.entry.yaml +++ b/fraud-bot-protection/castle/registry.entry.yaml @@ -14,12 +14,12 @@ description: >- useCase: >- A B2C product wants to keep credential-stuffing and account-takeover attempts out of its login flow without blocking legitimate users behind a CAPTCHA. Castle scores each login in real time; the integration applies an allow / challenge - / deny decision before the session is issued, with challenge events flagged for MFA step-up in the client. + / deny decision before the session is issued, and requires a second factor for challenge events. coreFunctionality: |- - - Receives Ory post-login and post-registration Action webhooks. - - Forwards the identity, request context, and Castle device token to Castle's Risk API. - - Translates Castle's response into Ory flow control: 400 to block, 200 + requires_mfa_stepup=true to step up, 200 with risk score in metadata to allow. - - Records the risk assessment in identity metadata for downstream auditing and policy decisions. + - Receives a sync login action (response.parse: true) and a registration action that runs after the identity is saved. + - Forwards the identity ID, client IP (True-Client-Ip), request headers, and Castle request token to Castle's Risk API. + - At login: 403 with a messages body to block, 200 with required_aal aal2 to require a second factor (webhook_response_directives feature flag), 200 to allow. + - At registration: reports the event; on deny, optionally deactivates the identity through the Ory Admin API. - Fails open on Castle outages so authentication never breaks because of Castle availability. oryMechanism: - actions diff --git a/fraud-bot-protection/castle/webhook/.env.example b/fraud-bot-protection/castle/webhook/.env.example index e4ba92e..9a0ef4a 100644 --- a/fraud-bot-protection/castle/webhook/.env.example +++ b/fraud-bot-protection/castle/webhook/.env.example @@ -12,5 +12,10 @@ CASTLE_API_BASE=https://api.castle.io CASTLE_CHALLENGE_THRESHOLD=0.6 CASTLE_DENY_THRESHOLD=0.9 +# Optional: Ory SDK URL and a project API key. When both are set, a denied +# registration deactivates the new identity through the Admin API. +ORY_SDK_URL= +ORY_ADMIN_API_KEY= + # Port the handler listens on. PORT=3000 diff --git a/fraud-bot-protection/castle/webhook/server.ts b/fraud-bot-protection/castle/webhook/server.ts index ba5f6b6..efec229 100644 --- a/fraud-bot-protection/castle/webhook/server.ts +++ b/fraud-bot-protection/castle/webhook/server.ts @@ -2,9 +2,11 @@ // // Castle.io <> Ory Actions webhook handler. // -// Receives post-login and post-registration events from Ory, calls Castle's -// Risk API, and translates the response into Ory's flow control: allow, -// challenge (flag identity for MFA step-up), or deny (block the flow). +// Receives post-login and post-registration events from Ory and calls Castle's +// Risk API. At login, the handler allows the sign-in, requires a second factor +// (challenge), or blocks it (deny). Registration events are reported after the +// identity is saved; a deny there can deactivate the identity through the Ory +// Admin API when ORY_SDK_URL and ORY_ADMIN_API_KEY are set. // // Castle authenticates server-to-server with HTTP Basic auth: empty username, // API secret as password. @@ -23,6 +25,8 @@ const CASTLE_API_SECRET = process.env.CASTLE_API_SECRET; const CASTLE_API_BASE = process.env.CASTLE_API_BASE || "https://api.castle.io"; const CHALLENGE_THRESHOLD = parseFloat(process.env.CASTLE_CHALLENGE_THRESHOLD || "0.6"); const DENY_THRESHOLD = parseFloat(process.env.CASTLE_DENY_THRESHOLD || "0.9"); +const ORY_SDK_URL = process.env.ORY_SDK_URL; +const ORY_ADMIN_API_KEY = process.env.ORY_ADMIN_API_KEY; if (!ORY_WEBHOOK_SECRET || !CASTLE_API_SECRET) { console.error("ORY_WEBHOOK_SECRET and CASTLE_API_SECRET must be set in .env"); @@ -30,18 +34,10 @@ if (!ORY_WEBHOOK_SECRET || !CASTLE_API_SECRET) { } interface OryWebhookBody { - identity: { - id: string; - traits?: { email?: string }; - metadata_public?: Record; - created_at?: string; - }; - flow: { - id: string; - type: string; - transient_payload?: { castle_request_token?: string } & Record; - }; - request_headers?: Record; + request_token?: string | null; + user: { id: string; email?: string | null; registered_at?: string | null }; + ip?: string | null; + headers?: Record; } interface CastleRiskResponse { @@ -51,13 +47,6 @@ interface CastleRiskResponse { device?: { token?: string; fingerprint?: string }; } -interface RiskAssessment { - score: number; - action: CastleRiskResponse["policy"]["action"]; - policy_name: string; - assessed_at: string; -} - const app = express(); app.use(express.json()); @@ -75,11 +64,6 @@ function verifyWebhookSecret(req: Request, res: Response, next: NextFunction) { const CASTLE_AUTH = `Basic ${Buffer.from(`:${CASTLE_API_SECRET}`).toString("base64")}`; -function clientIp(headers: Record | undefined): string { - const xff = headers?.["x-forwarded-for"]?.[0]; - return xff ? (xff.split(",")[0]?.trim() ?? "0.0.0.0") : "0.0.0.0"; -} - function flatHeaders( headers: Record | undefined, ): Record { @@ -99,17 +83,16 @@ async function callCastle( type: eventType, status: "$succeeded", user: { - id: payload.identity.id, - email: payload.identity.traits?.email, - registered_at: payload.identity.created_at, + id: payload.user.id, + ...(payload.user.email && { email: payload.user.email }), + ...(payload.user.registered_at && { registered_at: payload.user.registered_at }), }, context: { - ip: clientIp(payload.request_headers), - headers: flatHeaders(payload.request_headers), + ip: payload.ip, + headers: flatHeaders(payload.headers), }, }; - const requestToken = payload.flow?.transient_payload?.castle_request_token; - if (requestToken) body.request_token = requestToken; + if (payload.request_token) body.request_token = payload.request_token; const res = await fetch(`${CASTLE_API_BASE}/v1/risk`, { method: "POST", @@ -124,47 +107,64 @@ async function callCastle( return (await res.json()) as CastleRiskResponse; } +// Ory Kratos Identities rejects the flow and shows `text` to the user only for +// a 4xx response in this shape. +function rejectFlow(res: Response, id: number, text: string) { + res.status(403).json({ + messages: [{ instance_ptr: "#/", messages: [{ id, text, type: "error" }] }], + }); +} + +async function deactivateIdentity(identityId: string): Promise { + if (!ORY_SDK_URL || !ORY_ADMIN_API_KEY) { + console.log(`Castle denied registration ${identityId}: ORY_SDK_URL / ORY_ADMIN_API_KEY unset, not deactivating`); + return; + } + const res = await fetch(`${ORY_SDK_URL}/admin/identities/${identityId}`, { + method: "PATCH", + headers: { authorization: `Bearer ${ORY_ADMIN_API_KEY}`, "content-type": "application/json" }, + body: JSON.stringify([{ op: "replace", path: "/state", value: "inactive" }]), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`Ory patch ${res.status}: ${detail}`); + } +} + app.get("/health", (_req, res) => { res.json({ ok: true }); }); -// Post-login: synchronous interrupt-capable hook. -// risk >= DENY_THRESHOLD or policy.action === "deny" -> 400 (block) -// risk >= CHALLENGE_THRESHOLD or policy.action === "challenge" -> 200 with requires_mfa_stepup=true -// otherwise -> 200, risk recorded in metadata_public -// Castle outage falls open: 200 with empty body so authentication continues. +// Login, configured with response.parse: true: +// risk >= DENY_THRESHOLD or policy.action === "deny" -> 403 with a message (block) +// risk >= CHALLENGE_THRESHOLD or policy.action === "challenge" -> 200 with +// required_aal: aal2, which requires the webhook_response_directives feature +// flag and is ignored without it +// otherwise -> 200 +// Ory doesn't save identity changes returned at login, so the handler returns +// no metadata. A Castle outage falls open so authentication continues. app.post( "/castle/login", verifyWebhookSecret, async (req: Request, res: Response) => { try { const result = await callCastle(req.body, "$login"); - const assessment: RiskAssessment = { - score: result.risk, - action: result.policy.action, - policy_name: result.policy.name, - assessed_at: new Date().toISOString(), - }; + console.log( + `Castle login: user=${req.body.user.id} risk=${result.risk} action=${result.policy.action}`, + ); if (result.risk >= DENY_THRESHOLD || result.policy.action === "deny") { - res.status(400).json({ - messages: [{ - instance_ptr: "#/", - message: "Login blocked due to suspicious activity. Please contact support.", - type: "error", - }], - }); + rejectFlow(res, 9000401, "Sign-in blocked. Please contact support."); return; } - const requires_mfa_stepup = - result.risk >= CHALLENGE_THRESHOLD || result.policy.action === "challenge"; + if (result.risk >= CHALLENGE_THRESHOLD || result.policy.action === "challenge") { + res.status(200).json({ required_aal: "aal2" }); + return; + } - res.status(200).json({ - identity: { - metadata_public: { risk_assessment: assessment, requires_mfa_stepup }, - }, - }); + res.status(200).json({}); } catch (err) { console.error("Castle login risk assessment failed:", (err as Error).message); res.status(200).json({}); @@ -172,35 +172,21 @@ app.post( }, ); -// Post-registration: same shape, registration-specific event type. Risk goes -// into metadata_admin so it's available to support tooling but not exposed. +// Registration, configured with response.ignore: true so it runs after the +// identity is saved and receives its real ID. Ory doesn't read this response. app.post( "/castle/registration", verifyWebhookSecret, async (req: Request, res: Response) => { try { const result = await callCastle(req.body, "$registration"); - const assessment: RiskAssessment = { - score: result.risk, - action: result.policy.action, - policy_name: result.policy.name, - assessed_at: new Date().toISOString(), - }; - + console.log( + `Castle registration: user=${req.body.user.id} risk=${result.risk} action=${result.policy.action}`, + ); if (result.policy.action === "deny") { - res.status(400).json({ - messages: [{ - instance_ptr: "#/", - message: "Registration blocked. Please try again or contact support.", - type: "error", - }], - }); - return; + await deactivateIdentity(req.body.user.id); } - - res.status(200).json({ - identity: { metadata_admin: { risk_assessment: assessment } }, - }); + res.status(200).json({}); } catch (err) { console.error("Castle registration risk assessment failed:", (err as Error).message); res.status(200).json({}); diff --git a/fraud-bot-protection/recaptcha/README.md b/fraud-bot-protection/recaptcha/README.md index 4ce9706..78f4d3c 100644 --- a/fraud-bot-protection/recaptcha/README.md +++ b/fraud-bot-protection/recaptcha/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Community contributors -[Google reCAPTCHA](https://developers.google.com/recaptcha) is a bot-protection service. **v2** issues a visible (or invisible) challenge and returns a pass/fail token; **v3** runs entirely in the background and returns a score from `0.0` (bot) to `1.0` (human). Ory Network natively integrates Cloudflare Turnstile but not reCAPTCHA, so this integration adds reCAPTCHA via a sync pre-flow Action webhook that validates the client-side token against Google's `siteverify` API and either lets the flow proceed or interrupts it with a user-facing message. +[Google reCAPTCHA](https://developers.google.com/recaptcha) is a bot-protection service. **v2** issues a visible (or invisible) challenge and returns a pass/fail token; **v3** runs entirely in the background and returns a score from `0.0` (bot) to `1.0` (human). Ory Network natively integrates Cloudflare Turnstile but not reCAPTCHA, so this integration adds reCAPTCHA via a synchronous Action webhook when the user submits a flow that validates the client-side token against Google's `siteverify` API and either lets the flow proceed or interrupts it with a user-facing message. **Type:** webhook (Ory Action calls a handler during a flow) **Docs page:** [ory.com/docs/integrates-with/fraud-bot-protection/recaptcha](https://www.ory.com/docs/integrates-with/fraud-bot-protection/recaptcha) @@ -17,7 +17,7 @@ A consumer-facing product wants to stop bot signups and credential-stuffing atte 2. On submission, Ory Kratos Identities fires the sync Action webhook to this handler. The handler verifies the shared secret. 3. The handler POSTs `{secret, response, remoteip}` to Google's `https://www.google.com/recaptcha/api/siteverify` (form-urlencoded). 4. For v2: pass if `success: true`. For v3: pass if `success: true` AND `score >= RECAPTCHA_SCORE_THRESHOLD` AND (when set) `action == RECAPTCHA_EXPECTED_ACTION`. -5. Pass returns `200` with an empty body (flow continues). Fail returns `400` with `{messages:[{message:"…", type:"error"}]}` which Ory renders as a flow-level error message. Verify outages fail **closed** — a Google-API outage rejects the flow rather than silently letting bots through. +5. Pass returns `200` with an empty body (flow continues). Fail returns `400` with `{"messages":[{"instance_ptr":"#/","messages":[{"id":9003001,"text":"CAPTCHA verification failed.","type":"error"}]}]}` which Ory renders as a flow-level error message. Verify outages fail **closed** — a Google-API outage rejects the flow rather than silently letting bots through. ## Prerequisites @@ -42,7 +42,7 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. In the Ory Console, configure the Action hooks using the snippets in [`ory-actions.yaml`](ory-actions.yaml), on the `after` trigger for registration and login. Ory Kratos Identities runs `before` actions when it creates the flow, so a `before` handler never receives `transient_payload` and cannot see the token. +1. In the Ory Console, configure the Action hooks using the snippets in [`ory-actions.yaml`](ory-actions.yaml), in the method-specific `after` lists for registration and login, such as `registration.after.password.hooks`. Repeat them for every enabled method and keep `session` in the registration lists. Global lists can be overridden by method lists. On Ory Network, replace `file://` bodies with `base64://`. Ory Kratos Identities runs `before` actions when it creates the flow, so a `before` handler never receives `transient_payload` and cannot see the token. 2. The body template is [`jsonnet/verify.jsonnet`](jsonnet/verify.jsonnet). 3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the hook config. 4. In your custom UI, load `recaptcha/api.js`, generate a token, and include it in `transient_payload.recaptcha_token` on the flow submission. A working browser snippet is in the [docs page](https://ory.com/docs/integrates-with/fraud-bot-protection/recaptcha). diff --git a/fraud-bot-protection/recaptcha/jsonnet/verify.jsonnet b/fraud-bot-protection/recaptcha/jsonnet/verify.jsonnet index 5ac4a8e..2744e2f 100644 --- a/fraud-bot-protection/recaptcha/jsonnet/verify.jsonnet +++ b/fraud-bot-protection/recaptcha/jsonnet/verify.jsonnet @@ -10,7 +10,7 @@ function(ctx) { flow: { id: ctx.flow.id, type: ctx.flow.type, - transient_payload: if std.objectHas(ctx.flow, "transient_payload") then ctx.flow.transient_payload else {}, + transient_payload: if std.get(ctx.flow, "transient_payload", null) == null then {} else ctx.flow.transient_payload, }, identity: if std.objectHas(ctx, "identity") then { id: ctx.identity.id, diff --git a/fraud-bot-protection/recaptcha/ory-actions.yaml b/fraud-bot-protection/recaptcha/ory-actions.yaml index 9f5e5ec..f216c37 100644 --- a/fraud-bot-protection/recaptcha/ory-actions.yaml +++ b/fraud-bot-protection/recaptcha/ory-actions.yaml @@ -1,7 +1,6 @@ # Google reCAPTCHA verification hooks. # -# Both hooks run synchronously on the after trigger (response.parse: true, -# can_interrupt: true). The handler returns 400 with a flow message when the +# Both hooks run synchronously on the after trigger (response.parse: true). The handler returns 400 with a flow message when the # token is missing, invalid, or below the v3 score threshold; 200 to let the # flow proceed. On login the hook runs before Ory issues a session, on # registration before Ory persists the identity, so either can still block. @@ -13,41 +12,59 @@ # before hooks: Ory runs those when it creates the flow, before the user has # submitted anything. +# On Ory Network, use base64:// for body. +# Add the same lists for other methods you enable, such as saml. + selfservice: flows: registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/recaptcha/verify - method: POST - body: file://./jsonnet/verify.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_captcha_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/recaptcha/verify + method: POST + body: file://./jsonnet/verify.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_captcha_hooks + code: + hooks: *registration_captcha_hooks + passkey: + hooks: *registration_captcha_hooks + webauthn: + hooks: *registration_captcha_hooks login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/recaptcha/verify - method: POST - body: file://./jsonnet/verify.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_captcha_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/recaptcha/verify + method: POST + body: file://./jsonnet/verify.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_captcha_hooks + code: + hooks: *login_captcha_hooks + passkey: + hooks: *login_captcha_hooks + webauthn: + hooks: *login_captcha_hooks diff --git a/fraud-bot-protection/recaptcha/webhook/server.ts b/fraud-bot-protection/recaptcha/webhook/server.ts index e8e66dc..ed89bf8 100644 --- a/fraud-bot-protection/recaptcha/webhook/server.ts +++ b/fraud-bot-protection/recaptcha/webhook/server.ts @@ -33,7 +33,9 @@ const RECAPTCHA_EXPECTED_ACTION = process.env.RECAPTCHA_EXPECTED_ACTION || ""; const SITEVERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"; if (!ORY_WEBHOOK_SECRET || !RECAPTCHA_SECRET_KEY) { - console.error("ORY_WEBHOOK_SECRET and RECAPTCHA_SECRET_KEY must be set in .env"); + console.error( + "ORY_WEBHOOK_SECRET and RECAPTCHA_SECRET_KEY must be set in .env", + ); process.exit(1); } @@ -71,13 +73,17 @@ function verifyWebhookSecret(req: Request, res: Response, next: NextFunction) { function flowError(message: string) { return { - messages: [{ instance_ptr: "#/", message, type: "error" }], + messages: [ + { + instance_ptr: "#/", + messages: [{ id: 9003001, text: message, type: "error" }], + }, + ], }; } function extractClientIp(body: OryWebhookBody): string | undefined { - const xff = body?.request_headers?.["x-forwarded-for"]?.[0]; - return xff ? xff.split(",")[0]?.trim() : undefined; + return body?.request_headers?.["True-Client-Ip"]?.[0]; } async function verifyToken( @@ -106,7 +112,10 @@ async function verifyToken( if (RECAPTCHA_VERSION === "v2") { return data.success ? { ok: true } - : { ok: false, reason: `verification failed: ${(data["error-codes"] ?? []).join(",")}` }; + : { + ok: false, + reason: `verification failed: ${(data["error-codes"] ?? []).join(",")}`, + }; } // v3 checks @@ -153,7 +162,11 @@ app.post( if (!token || typeof token !== "string") { res .status(400) - .json(flowError("Please complete the CAPTCHA verification before submitting.")); + .json( + flowError( + "Please complete the CAPTCHA verification before submitting.", + ), + ); return; } @@ -175,7 +188,11 @@ app.post( // Fail closed: a verify outage shouldn't quietly let bots through. res .status(400) - .json(flowError("CAPTCHA verification is temporarily unavailable. Please try again.")); + .json( + flowError( + "CAPTCHA verification is temporarily unavailable. Please try again.", + ), + ); } }, ); diff --git a/fraud-bot-protection/sift/README.md b/fraud-bot-protection/sift/README.md index 8580b74..4514caf 100644 --- a/fraud-bot-protection/sift/README.md +++ b/fraud-bot-protection/sift/README.md @@ -11,13 +11,13 @@ Two flows in one handler: -**Check on submission (block on fail)** -1. Ory Action on `registration.after` / `login.after` with `can_interrupt: true` calls handler. -2. Handler reads the user's current Sift abuse score (`GET /v205/users/{user_id}/score`). -3. If the score exceeds a configurable threshold, handler returns a failure and Ory blocks the flow. +**Check on sign-in (block on fail)** +1. Ory Action on `login.after` with `response.parse: true` calls handler. +2. Handler reads the user's current Sift abuse score (`GET /v205/users/{user_id}/score`), using the Ory identity ID as `user_id`. +3. If the score exceeds a configurable threshold, handler responds with a `4xx` status code (not `429`) and a `messages` body, and Ory blocks the sign-in. **Post-event reporting (signal collection)** -1. Ory Action on `registration.after` / `login.after` (async, `response.ignore: true`) calls handler. +1. Ory Actions on `registration.after` and `login.after` with `response.ignore: true` call handler. At registration, this action runs after the identity is saved, so it receives the real identity ID. 2. Handler POSTs a `$create_account` / `$login` event to `https://api.sift.com/v205/events` with the user's data (IP, user agent, session, traits). 3. Sift updates its internal model and the user's score for future checks. @@ -25,19 +25,40 @@ Both flows must run for Sift to be useful: the score lookup alone has no signal, > Note: the blocking hook is an `after` hook. Ory Kratos Identities runs `before` actions when it creates the flow, before it > has resolved an identity, so a `before` handler has no `user_id` to score. The `after` login hook runs before it issues a -> session, so `can_interrupt: true` still blocks. +> session, so a `4xx` response still blocks. A `registration.after` action with `response.parse: true` runs before the identity +> is saved and receives the ID `00000000-0000-0000-0000-000000000000`, so send `$create_account` from an action without +> `response.parse`. ## Setup outline 1. Sign up at Sift; copy the **API Key** (server-side, all calls). -2. Configure two Ory Actions on the `after` trigger, one sync with `can_interrupt: true` and one async with - `response.ignore: true`, both pointing at your handler. -3. Handler implements both endpoints, reusing the Sift API key. +2. Configure the Ory Actions in the method-specific hook lists, such as `selfservice.flows.login.after.password.hooks` and + `selfservice.flows.registration.after.password.hooks`, and repeat them for every method you enable, such as `oidc` or `code`. + Ory runs the global `after.hooks` list only for a method that has no hooks of its own. Keep the `session` hook last in the + registration lists. + - Score check: `login.after`, `response.parse: true`. + - Event reporter: `registration.after` and `login.after`, `response.ignore: true`. +3. Handler implements both endpoints, reusing the Sift API key. Read the client IP from the `True-Client-Ip` request header and + the user agent from `User-Agent`, null-safely, for example `std.get(ctx.request_headers, 'True-Client-Ip', [null])[0]`. +4. To block, respond with a `4xx` status code and this body. Ory shows the `text` to the user: + + ```json + { + "messages": [ + { + "instance_ptr": "#/", + "messages": [{ "id": 123, "text": "Sign-in blocked. Please contact support.", "type": "error" }] + } + ] + } + ``` + + Ory waits about 30 seconds for the handler and retries a `5xx` response (three attempts in total). ## Notable - Sift uses **reserved event names** (`$create_account`, `$login`, `$transaction`, etc.) prefixed with `$`; user IDs should be stable across sessions. -- Threshold tuning is a calibration exercise — start permissive (block on score > 80) and tighten as you collect data. +- The Score API returns scores between 0 and 1; the Sift Console shows them on a 0 to 100 scale. Threshold tuning is a calibration exercise — start permissive (block above `0.8`) and tighten as you collect data. - Sift's value is the **longitudinal model** — single events have limited signal; the integration's payoff grows over time as Sift learns the user base. ## Status diff --git a/fraud-bot-protection/sift/registry.entry.yaml b/fraud-bot-protection/sift/registry.entry.yaml index 82515e0..540b14e 100644 --- a/fraud-bot-protection/sift/registry.entry.yaml +++ b/fraud-bot-protection/sift/registry.entry.yaml @@ -9,16 +9,16 @@ type: webhook maintainedBy: Community contributors description: >- Digital trust & safety via Sift — real-time ML-based fraud and account-abuse scoring. Wired into Ory via - two Actions: a sync check on submission (registration.after / login.after with can_interrupt) reading the - user's current Sift abuse score and blocking when above threshold, plus an async post-event reporter - emitting Sift events that feed the model. + two Actions: a sync check at sign-in (login.after with response.parse) reading the user's current Sift abuse + score and blocking with a 4xx when above threshold, plus a post-event reporter (response.ignore) emitting Sift + events that feed the model. useCase: >- A consumer product (marketplace, fintech, gaming) needs ML-driven account-abuse prevention beyond bot defense — repeat-fraudster detection, payment-fraud signals, network-effect risk — and is willing to feed Sift the event stream needed to build a useful longitudinal model. coreFunctionality: |- - - Two flows must run for Sift to be useful: pre-flow check (block on fail) AND post-event reporter (signal collection). - - Pre-flow: GET https://api.sift.com/v205/users/{user_id}/score; block if score > threshold (start permissive at 80, tighten with data). + - Two flows must run for Sift to be useful: sign-in check (block on fail) AND post-event reporter (signal collection). + - Sign-in check: GET https://api.sift.com/v205/users/{user_id}/score; block if score > threshold (scores are 0-1 in the API; start permissive at 0.8, tighten with data). - Post-event: POST https://api.sift.com/v205/events with reserved event names ($create_account, $login, $transaction) including IP, user agent, session, traits. - Both flows authenticated via a single server-side API Key. - User IDs must be stable across sessions — Sift's model is keyed on user_id. diff --git a/identity-verification/equifax/README.md b/identity-verification/equifax/README.md index b39032d..77e23a5 100644 --- a/identity-verification/equifax/README.md +++ b/identity-verification/equifax/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Community contributors -[Equifax](https://www.equifax.com) is a global credit bureau that provides credit-anchored identity verification through its Digital Identity Trust APIs. This integration calls Equifax from an Ory Action webhook during registration to verify the user's identity attributes against credit-bureau data and returns a decision plus trust score so Ory's post-flow Action can gate the flow. +[Equifax](https://www.equifax.com) is a global credit bureau that provides credit-anchored identity verification through its Digital Identity Trust APIs. This integration calls Equifax from an Ory Action webhook during registration to verify the user's identity attributes against credit-bureau data and rejects the registration unless Equifax returns a passing decision. **Type:** webhook (Ory Action calls a handler during a flow) **Docs page:** [ory.com/docs/integrates-with/identity-verification/equifax](https://www.ory.com/docs/integrates-with/identity-verification/equifax) @@ -14,10 +14,14 @@ A regulated B2C product (fintech, lending, insurance) needs credit-anchored iden ## How it works 1. A user completes the registration form, providing the identity attributes Equifax needs (first name, last name, date of birth, optional SSN last 4, address). -2. Ory fires the sync post-registration Action webhook to this handler. The handler verifies the shared secret. +2. Ory calls this handler from a registration action with `response.parse: true`, before it saves the identity. The handler verifies the shared secret. 3. The handler exchanges its Equifax OAuth2 client credentials for an access token using the configured `EQUIFAX_SCOPE` and caches it (refresh ~5 minutes before expiry to amortize the round-trip). 4. The handler calls Equifax's identity-verification endpoint with the consumer payload built from the identity traits. -5. The handler echoes Equifax's `decision`/`outcome`, `trust_score`, and `reference_id` back to Ory. Ory's Action consumes the response and applies your policy (allow, block, route to step-up). +5. The handler applies your policy. Ory doesn't interpret vendor decisions, and any `200` lets the registration through: + - **Pass** (`decision` or `outcome` in `EQUIFAX_PASS_DECISIONS`, default `APPROVE,PASS,ACCEPT`): `200` with `{"identity": {"external_id": "", "metadata_admin": {"equifax_verification_status": "verified", ...}}}`. Ory saves both with the new identity. + - **Anything else:** `400` with a `messages` body, which rejects the registration. + + Set `EQUIFAX_PASS_DECISIONS` to the passing decision values of your Equifax product. ## Prerequisites @@ -44,11 +48,29 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. In the Ory Console, configure the Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml). +1. Configure the action using the snippet in [`ory-actions.yaml`](ory-actions.yaml). 2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet) — Equifax verification typically needs `first_name`, `last_name`, `date_of_birth`, `ssn_last_four`, and `address`. Extend the identity schema to carry those fields if they aren't already present. -3. Keep `response.parse: true` and `response.ignore: false` so Ory consumes Equifax's verdict. +3. Keep `response.parse: true` so the handler can reject the registration and return metadata. 4. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the hook config. +- Put the hook in the method-specific list, such as `selfservice.flows.registration.after.password.hooks`, and repeat it for every method you enable, such as `oidc` or `code`. Ory runs the global `registration.after.hooks` list only for a method that has no hooks of its own. Keep the `session` hook last. +- `body: file://./jsonnet/...` works for self-hosted Ory Kratos. On Ory Network, encode the template with `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and set `body: base64://`. +- With `response.parse: true`, the action runs before the identity is saved, so `ctx.identity.id` is `00000000-0000-0000-0000-000000000000`. The handler sends its own reference to the vendor and returns it as the identity's `external_id`; look the identity up later with `GET /admin/identities/by/external/`. +- Ory waits about 30 seconds for the handler and retries a `5xx` response (three attempts in total). + +To reject the flow, the handler responds with a `4xx` status code and this body. Ory shows the `text` to the user: + +```json +{ + "messages": [ + { + "instance_ptr": "#/", + "messages": [{ "id": 9000102, "text": "We couldn't verify your identity.", "type": "error" }] + } + ] +} +``` + Detailed setup, identity-schema extensions for KYC fields, and per-product Equifax scopes: see the [docs page](https://ory.com/docs/integrates-with/identity-verification/equifax). ## Troubleshooting diff --git a/identity-verification/equifax/jsonnet/identity.jsonnet b/identity-verification/equifax/jsonnet/identity.jsonnet index ca692df..537ac9f 100644 --- a/identity-verification/equifax/jsonnet/identity.jsonnet +++ b/identity-verification/equifax/jsonnet/identity.jsonnet @@ -1,9 +1,13 @@ -function(ctx) { - identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.first else null, - last_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.last else null, - date_of_birth: if std.objectHas(ctx.identity.traits, "date_of_birth") then ctx.identity.traits.date_of_birth else null, - ssn_last_four: if std.objectHas(ctx.identity.traits, "ssn_last_four") then ctx.identity.traits.ssn_last_four else null, - address: if std.objectHas(ctx.identity.traits, "address") then ctx.identity.traits.address else null, -} +// The identity is not saved yet, so ctx.identity.id is the nil UUID. Send +// only values the user submitted. +function(ctx) + local traits = std.get(ctx.identity, 'traits', {}); + local name = std.get(traits, 'name', null); + { + email: std.get(traits, 'email', null), + first_name: if name == null then null else std.get(name, 'first', null), + last_name: if name == null then null else std.get(name, 'last', null), + date_of_birth: std.get(traits, 'date_of_birth', null), + ssn_last_four: std.get(traits, 'ssn_last_four', null), + address: std.get(traits, 'address', null), + } diff --git a/identity-verification/equifax/ory-actions.yaml b/identity-verification/equifax/ory-actions.yaml index e9042e8..6921a1a 100644 --- a/identity-verification/equifax/ory-actions.yaml +++ b/identity-verification/equifax/ory-actions.yaml @@ -1,15 +1,36 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/equifax/verify-identity - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: false - parse: true +# Equifax identity verification at registration. +# +# The action runs before the identity is saved (response.parse: true), so the +# handler can reject the registration or return metadata to store with the new +# identity. The password lists are aliased into the oidc, code, passkey, and +# webauthn lists, so every sign-in method runs the same hooks. Alias them into +# any other method you enable, such as saml, the same way. + +selfservice: + flows: + registration: + after: + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/equifax/verify-identity + method: POST + body: file://./jsonnet/identity.jsonnet + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + parse: true + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks diff --git a/identity-verification/equifax/registry.entry.yaml b/identity-verification/equifax/registry.entry.yaml index f791f77..b7fce8d 100644 --- a/identity-verification/equifax/registry.entry.yaml +++ b/identity-verification/equifax/registry.entry.yaml @@ -10,17 +10,17 @@ maintainedBy: Community contributors description: >- Equifax provides credit-anchored identity verification through its Digital Identity Trust APIs. This integration calls Equifax from an Ory Action webhook during registration to verify identity attributes against credit-bureau data and - returns a decision plus trust score so Ory's post-flow Action can gate the flow. + rejects the registration unless Equifax returns a passing decision. useCase: >- A regulated B2C product (fintech, lending, insurance) needs credit-anchored identity proofing on every signup to satisfy KYC requirements. Equifax returns a deterministic decision and confidence score; the integration runs the lookup inline with Ory registration so high-confidence identities pass through and low-confidence ones can be blocked or routed to step-up verification. coreFunctionality: |- - - Sync post-registration Ory Action webhook (response.parse: true, can_interrupt: true). + - Registration action with response.parse: true in the method-specific hook lists, before the identity is saved. - Exchanges Equifax OAuth2 client credentials for an access token, cached and refreshed ~5 min before expiry. - Calls Equifax's identity-verification endpoint with first_name, last_name, date_of_birth, optional ssn_last_four, and address. - - Returns decision/outcome, trust_score, and reference_id to Ory so the post-flow Action can apply policy. + - Passing decisions (EQUIFAX_PASS_DECISIONS) return 200 with the result in metadata_admin and the handler's reference as external_id; any other decision returns 400 with a messages body to reject the registration. - 502 on Equifax errors / outages so the verdict is never silently a pass. oryMechanism: - actions diff --git a/identity-verification/equifax/webhook/.env.example b/identity-verification/equifax/webhook/.env.example index 81a3fb1..4d00218 100644 --- a/identity-verification/equifax/webhook/.env.example +++ b/identity-verification/equifax/webhook/.env.example @@ -8,6 +8,10 @@ EQUIFAX_CLIENT_SECRET= # Equifax product / scope identifier — varies by Equifax product (e.g., usca-prsl-vfy). EQUIFAX_SCOPE= +# Comma-separated decision values that let a registration through. Decision +# values vary by Equifax product; set the passing values of yours. +EQUIFAX_PASS_DECISIONS=APPROVE,PASS,ACCEPT + # Shared secret with Ory's webhook configuration (X-Webhook-Secret header value). ORY_WEBHOOK_SECRET= diff --git a/identity-verification/equifax/webhook/server.ts b/identity-verification/equifax/webhook/server.ts index 62f5139..612c1f7 100644 --- a/identity-verification/equifax/webhook/server.ts +++ b/identity-verification/equifax/webhook/server.ts @@ -3,8 +3,8 @@ // Reference webhook handler for the Ory <> Equifax integration. // Receives an Ory Action webhook, exchanges client credentials for an OAuth 2.0 // access token (cached, refreshed ~5 min before expiry), and calls the Equifax -// Digital Identity Trust API. Returns the decision and trust score so Ory's -// post-flow Action can apply policy (e.g., gate a registration). +// Digital Identity Trust API. A passing decision returns 200 with the result +// stored on the new identity; any other decision rejects the registration. import "dotenv/config"; import crypto from "node:crypto"; @@ -20,6 +20,12 @@ const EQUIFAX_API_BASE = process.env.EQUIFAX_API_BASE || "https://api.sandbox.eq const EQUIFAX_CLIENT_ID = process.env.EQUIFAX_CLIENT_ID; const EQUIFAX_CLIENT_SECRET = process.env.EQUIFAX_CLIENT_SECRET; const EQUIFAX_SCOPE = process.env.EQUIFAX_SCOPE; +// Decision values vary by Equifax product. Only these values let the +// registration through; set them to the passing values of your product. +const EQUIFAX_PASS_DECISIONS = (process.env.EQUIFAX_PASS_DECISIONS || "APPROVE,PASS,ACCEPT") + .split(",") + .map((s) => s.trim().toUpperCase()) + .filter(Boolean); if (!ORY_WEBHOOK_SECRET || !EQUIFAX_CLIENT_ID || !EQUIFAX_CLIENT_SECRET || !EQUIFAX_SCOPE) { console.error( @@ -29,7 +35,6 @@ if (!ORY_WEBHOOK_SECRET || !EQUIFAX_CLIENT_ID || !EQUIFAX_CLIENT_SECRET || !EQUI } interface OryWebhookBody { - identity_id?: string; email?: string; first_name?: string; last_name?: string; @@ -92,6 +97,14 @@ async function getToken(): Promise { return cachedToken; } +// Ory Kratos Identities rejects the flow and shows `text` to the user only for +// a 4xx response in this shape. +function rejectFlow(res: Response, id: number, text: string) { + res.status(400).json({ + messages: [{ instance_ptr: "#/", messages: [{ id, text, type: "error" }] }], + }); +} + app.get("/health", (_req, res) => { res.json({ ok: true }); }); @@ -100,14 +113,17 @@ app.post( "/equifax/verify-identity", verifyWebhookSecret, async (req: Request, res: Response) => { - const { identity_id, email, first_name, last_name, date_of_birth, ssn_last_four, address } = - req.body; + const { email, first_name, last_name, date_of_birth, ssn_last_four, address } = req.body; if (!email || !first_name || !last_name) { - res.status(400).json({ error: "email, first_name, last_name are required" }); + rejectFlow(res, 9000101, "Please provide your first name, last name, and email address."); return; } + // The identity is not saved yet, so the handler creates its own reference + // and stores it as the identity's external_id. + const reference = crypto.randomUUID(); + try { const token = await getToken(); // The path varies by Equifax product — adjust to the product you've been entitled to. @@ -118,7 +134,7 @@ app.post( "content-type": "application/json", }, body: JSON.stringify({ - externalReferenceId: identity_id, + externalReferenceId: reference, consumer: { name: { firstName: first_name, lastName: last_name }, dateOfBirth: date_of_birth, @@ -137,11 +153,23 @@ app.post( } const result = (await verifyRes.json()) as EquifaxVerifyResponse; + const decision = (result.decision ?? result.outcome ?? "").toUpperCase(); + if (!EQUIFAX_PASS_DECISIONS.includes(decision)) { + console.log(`Equifax rejected registration: ref=${reference} decision=${decision || "none"}`); + rejectFlow(res, 9000102, "We couldn't verify your identity."); + return; + } + res.json({ - ok: true, - decision: result.decision ?? result.outcome, - trust_score: result.trustScore ?? null, - reference_id: result.referenceId ?? null, + identity: { + external_id: reference, + metadata_admin: { + equifax_verification_status: "verified", + equifax_decision: decision, + equifax_trust_score: result.trustScore ?? null, + equifax_reference_id: result.referenceId ?? null, + }, + }, }); } catch (err) { console.error("Verify error:", (err as Error).message); diff --git a/identity-verification/gbg-acuant/README.md b/identity-verification/gbg-acuant/README.md index d1c3ec7..afa95f0 100644 --- a/identity-verification/gbg-acuant/README.md +++ b/identity-verification/gbg-acuant/README.md @@ -6,7 +6,7 @@ ## Overview -GBG (which acquired Acuant) is a global identity verification provider with deep document and biometric coverage. This integration calls the GBG identity verification API from an Ory Actions webhook during registration to verify document + selfie evidence, and returns the GBG decision so Ory can gate the registration flow. +GBG (which acquired Acuant) is a global identity verification provider with deep document and biometric coverage. This integration calls the GBG identity verification API from an Ory Actions webhook during registration to verify document + selfie evidence, and rejects the registration unless GBG passes it. ## How it works @@ -21,10 +21,10 @@ Handler calls GBG GO Journey API with the identity payload ↓ GBG returns decision (PASS / REFER / FAIL) ↓ -Handler echoes decision back to Ory - ↓ -Ory Action consumes the response — fails the flow on REFER/FAIL, -or writes the verdict into identity.metadata for downstream policy +Handler turns the decision into a response: + PASS → 200 with the verdict for identity metadata + FAIL → 400 with a messages body, registration rejected + REFER → rejected, or let through when GBG_REFER_ACTION=allow ``` ## Prerequisites @@ -40,7 +40,7 @@ cd webhook/ cp .env.example .env # Fill GBG_API_BASE, GBG_API_TOKEN, GBG_JOURNEY_ID, ORY_WEBHOOK_SECRET npm install -node server.js +npm start ``` Endpoints: @@ -50,10 +50,29 @@ Endpoints: ## Configure Ory -1. Use the snippet in [`ory-actions.yaml`](ory-actions.yaml) to register the webhook on your registration flow's `after` hooks. +1. Use the snippet in [`ory-actions.yaml`](ory-actions.yaml) to register the webhook on your registration method's `after` hooks. 2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet) — extend it if your identity schema carries additional verification inputs (document image, selfie image, address). 3. Set `ORY_WEBHOOK_SECRET` in `.env` to match the `X-Webhook-Secret` value declared in the Ory hook config. -4. Set `response.parse: true` and `response.ignore: false` on the hook (already set in `ory-actions.yaml`) so Ory consumes the GBG decision. +4. Keep `response.parse: true` on the hook (already set in `ory-actions.yaml`) so the handler can reject the registration and return metadata. Ory doesn't interpret the GBG decision, and any `200` lets the registration through. +5. Set `GBG_REFER_ACTION=allow` to let `REFER` cases register and review them later; the default rejects them. + +- Put the hook in the method-specific list, such as `selfservice.flows.registration.after.password.hooks`, and repeat it for every method you enable, such as `oidc` or `code`. Ory runs the global `registration.after.hooks` list only for a method that has no hooks of its own. Keep the `session` hook last. +- `body: file://./jsonnet/...` works for self-hosted Ory Kratos. On Ory Network, encode the template with `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and set `body: base64://`. +- With `response.parse: true`, the action runs before the identity is saved, so `ctx.identity.id` is `00000000-0000-0000-0000-000000000000`. The handler sends its own reference to the vendor and returns it as the identity's `external_id`; look the identity up later with `GET /admin/identities/by/external/`. +- Ory waits about 30 seconds for the handler and retries a `5xx` response (three attempts in total). + +To reject the flow, the handler responds with a `4xx` status code and this body. Ory shows the `text` to the user: + +```json +{ + "messages": [ + { + "instance_ptr": "#/", + "messages": [{ "id": 9000202, "text": "We couldn't verify your identity documents.", "type": "error" }] + } + ] +} +``` ## Troubleshooting diff --git a/identity-verification/gbg-acuant/jsonnet/identity.jsonnet b/identity-verification/gbg-acuant/jsonnet/identity.jsonnet index 93e13f5..ce6594a 100644 --- a/identity-verification/gbg-acuant/jsonnet/identity.jsonnet +++ b/identity-verification/gbg-acuant/jsonnet/identity.jsonnet @@ -1,9 +1,13 @@ -function(ctx) { - identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.first else null, - last_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.last else null, - date_of_birth: if std.objectHas(ctx.identity.traits, "date_of_birth") then ctx.identity.traits.date_of_birth else null, - document_image: if std.objectHas(ctx.identity.traits, "document_image") then ctx.identity.traits.document_image else null, - selfie_image: if std.objectHas(ctx.identity.traits, "selfie_image") then ctx.identity.traits.selfie_image else null, -} +// The identity is not saved yet, so ctx.identity.id is the nil UUID. Send +// only values the user submitted. +function(ctx) + local traits = std.get(ctx.identity, 'traits', {}); + local name = std.get(traits, 'name', null); + { + email: std.get(traits, 'email', null), + first_name: if name == null then null else std.get(name, 'first', null), + last_name: if name == null then null else std.get(name, 'last', null), + date_of_birth: std.get(traits, 'date_of_birth', null), + document_image: std.get(traits, 'document_image', null), + selfie_image: std.get(traits, 'selfie_image', null), + } diff --git a/identity-verification/gbg-acuant/ory-actions.yaml b/identity-verification/gbg-acuant/ory-actions.yaml index 9f28ae2..d3903e1 100644 --- a/identity-verification/gbg-acuant/ory-actions.yaml +++ b/identity-verification/gbg-acuant/ory-actions.yaml @@ -1,15 +1,36 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/gbg/verify-identity - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: false # we want the verification result back to gate registration - parse: true +# GBG GO journey verification at registration. +# +# The action runs before the identity is saved (response.parse: true), so the +# handler can reject the registration or return metadata to store with the new +# identity. The password lists are aliased into the oidc, code, passkey, and +# webauthn lists, so every sign-in method runs the same hooks. Alias them into +# any other method you enable, such as saml, the same way. + +selfservice: + flows: + registration: + after: + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/gbg/verify-identity + method: POST + body: file://./jsonnet/identity.jsonnet + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + parse: true + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks diff --git a/identity-verification/gbg-acuant/registry.entry.yaml b/identity-verification/gbg-acuant/registry.entry.yaml index 01d0ecd..1a71944 100644 --- a/identity-verification/gbg-acuant/registry.entry.yaml +++ b/identity-verification/gbg-acuant/registry.entry.yaml @@ -9,16 +9,16 @@ type: webhook maintainedBy: Community contributors description: >- Run GBG (which acquired Acuant) identity verification — document + biometric — from an Ory Actions webhook - during registration. Calls the GBG GO Journey API, returns the GBG decision (PASS / REFER / FAIL) so Ory can - gate the registration flow, and writes the verdict to identity metadata for downstream policy. + during registration. Calls the GBG GO Journey API, lets PASS through with the verdict in identity metadata, and + rejects FAIL (and, by default, REFER) with a messages response. useCase: >- A regulated product wants document + biometric identity verification with GBG's deep international document coverage (long Acuant heritage), wired into a configurable GBG "journey" so the same Ory action can back several risk levels by swapping the journey id. coreFunctionality: |- - - One sync endpoint POST /gbg/verify-identity called from registration.after with response.parse: true so Ory consumes the GBG decision directly. + - One sync endpoint POST /gbg/verify-identity called from the method-specific registration.after hook lists with response.parse: true, before the identity is saved. - GBG-side flow defined as a "journey" (journey id is required); journey definition controls which evidence types are required (document, selfie, address, etc.). - - Decision values: PASS / REFER / FAIL — the action fails the registration on REFER/FAIL, or writes the verdict into identity.metadata for downstream policy. + - Decision values: PASS / REFER / FAIL — the handler returns 200 with the verdict on PASS and 400 with a messages body on FAIL; REFER is rejected unless GBG_REFER_ACTION=allow. - Authenticated via X-Webhook-Secret shared with the Ory hook config. - First-call cold-path latency can be 5-10s; handler caps at 15s. oryMechanism: diff --git a/identity-verification/gbg-acuant/webhook/.env.example b/identity-verification/gbg-acuant/webhook/.env.example index c6c430a..746c43d 100644 --- a/identity-verification/gbg-acuant/webhook/.env.example +++ b/identity-verification/gbg-acuant/webhook/.env.example @@ -7,6 +7,12 @@ GBG_API_TOKEN= # GBG journey / profile ID — identifies which verification journey to run. GBG_JOURNEY_ID= +# Journey API path. Override only if your tenant uses a different path. +GBG_API_PATH=/identity/journey/v1/journeys + +# What to do with a REFER result: "block" (default) or "allow". +GBG_REFER_ACTION=block + # Shared secret with Ory's webhook configuration (X-Webhook-Secret header value). ORY_WEBHOOK_SECRET= diff --git a/identity-verification/gbg-acuant/webhook/server.ts b/identity-verification/gbg-acuant/webhook/server.ts index c5b4ae5..3236f34 100644 --- a/identity-verification/gbg-acuant/webhook/server.ts +++ b/identity-verification/gbg-acuant/webhook/server.ts @@ -2,7 +2,8 @@ // // Reference webhook handler for the Ory <> GBG (Acuant) integration. // Receives an Ory Action webhook on registration, creates a Case on a GBG -// GO Journey, and returns the GBG decision so Ory can gate the flow. +// GO Journey, and turns the GBG decision into an Ory response: PASS lets the +// registration through, FAIL rejects it, and REFER follows GBG_REFER_ACTION. import "dotenv/config"; import crypto from "node:crypto"; @@ -18,6 +19,7 @@ const GBG_API_BASE = process.env.GBG_API_BASE || "https://api.gbgplc.com"; const GBG_API_TOKEN = process.env.GBG_API_TOKEN; const GBG_JOURNEY_ID = process.env.GBG_JOURNEY_ID; const GBG_API_PATH = process.env.GBG_API_PATH || "/identity/journey/v1/journeys"; +const GBG_REFER_ACTION = process.env.GBG_REFER_ACTION === "allow" ? "allow" : "block"; if (!ORY_WEBHOOK_SECRET || !GBG_API_TOKEN || !GBG_JOURNEY_ID) { console.error("ORY_WEBHOOK_SECRET, GBG_API_TOKEN and GBG_JOURNEY_ID must be set in .env"); @@ -25,7 +27,6 @@ if (!ORY_WEBHOOK_SECRET || !GBG_API_TOKEN || !GBG_JOURNEY_ID) { } interface OryWebhookBody { - identity_id?: string; email?: string; first_name?: string; last_name?: string; @@ -55,6 +56,14 @@ function verifyWebhookSecret(req: Request, res: Response, next: NextFunction) { next(); } +// Ory Kratos Identities rejects the flow and shows `text` to the user only for +// a 4xx response in this shape. +function rejectFlow(res: Response, id: number, text: string) { + res.status(400).json({ + messages: [{ instance_ptr: "#/", messages: [{ id, text, type: "error" }] }], + }); +} + app.get("/health", (_req, res) => { res.json({ ok: true }); }); @@ -63,14 +72,17 @@ app.post( "/gbg/verify-identity", verifyWebhookSecret, async (req: Request, res: Response) => { - const { identity_id, email, first_name, last_name, date_of_birth, document_image, selfie_image } = - req.body; + const { email, first_name, last_name, date_of_birth, document_image, selfie_image } = req.body; if (!email || !first_name || !last_name) { - res.status(400).json({ error: "email, first_name, last_name are required" }); + rejectFlow(res, 9000201, "Please provide your first name, last name, and email address."); return; } + // The identity is not saved yet, so the handler creates its own reference + // and stores it as the identity's external_id. + const reference = crypto.randomUUID(); + try { const gbgRes = await fetch( `${GBG_API_BASE}${GBG_API_PATH}/${GBG_JOURNEY_ID}/cases`, @@ -81,7 +93,7 @@ app.post( "content-type": "application/json", }, body: JSON.stringify({ - externalReference: identity_id, + externalReference: reference, person: { name: { given: first_name, family: last_name }, email, @@ -103,11 +115,20 @@ app.post( } const result = (await gbgRes.json()) as GbgCaseResponse; + const decision = (result.decision ?? result.outcome ?? "").toUpperCase(); + const pass = decision === "PASS" || (decision === "REFER" && GBG_REFER_ACTION === "allow"); + if (!pass) { + console.log(`GBG rejected registration: case=${result.id} decision=${decision || "none"}`); + rejectFlow(res, 9000202, "We couldn't verify your identity documents."); + return; + } + res.json({ - ok: true, - decision: result.decision ?? result.outcome, - case_id: result.id, - reasons: result.reasons ?? [], + identity: { + external_id: reference, + metadata_public: { gbg_decision: decision }, + metadata_admin: { gbg_case_id: result.id ?? null, gbg_reasons: result.reasons ?? [] }, + }, }); } catch (err) { console.error("Verify error:", (err as Error).message); diff --git a/identity-verification/id-me/README.md b/identity-verification/id-me/README.md index d204d19..4bc97d1 100644 --- a/identity-verification/id-me/README.md +++ b/identity-verification/id-me/README.md @@ -11,10 +11,11 @@ ID.me also offers **verified group claims** (Military, First Responder, Student, ## Setup -ID.me is OIDC-compliant; configure as a generic provider with explicit endpoints (no Kratos provider key for `id-me`): +ID.me is OIDC-compliant; configure it as a `generic` provider with the issuer URL. Ory reads the endpoints from `https://api.id.me/oidc/.well-known/openid-configuration`: | Setting | Value | | --- | --- | +| Issuer URL | `https://api.id.me/oidc` | | Authorization | `https://api.id.me/oauth/authorize` | | Token | `https://api.id.me/oauth/token` | | Userinfo | `https://api.id.me/api/public/v3/userinfo` | @@ -26,7 +27,27 @@ ID.me is OIDC-compliant; configure as a generic provider with explicit endpoints 1. Sign up at [developers.id.me](https://developers.id.me) for a developer account. 2. Create an application and add the Ory redirect URI as an approved Redirect URI. 3. Submit the application for ID.me review — required before scopes like `military`, `nurse`, `teacher` are released to the application. -4. Configure as a generic OIDC provider via Ory CLI (Console wizard's discovery flow doesn't work cleanly because ID.me requires explicit endpoints). +4. Configure ID.me as a generic OIDC provider in your identity configuration: + + ```yaml + selfservice: + methods: + oidc: + enabled: true + config: + providers: + - id: id-me + provider: generic + label: ID.me + client_id: + client_secret: + issuer_url: https://api.id.me/oidc + mapper_url: base64:// + scope: + - openid + ``` + + Add the group scopes that ID.me approved for your app to `scope`. The provider `id` must match the last path segment of the redirect URI. 5. Map the OIDC claims (and any verified-group claims) to identity traits / metadata via Jsonnet. ## Status diff --git a/identity-verification/jumio/README.md b/identity-verification/jumio/README.md index a746a09..561d029 100644 --- a/identity-verification/jumio/README.md +++ b/identity-verification/jumio/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Ory Engineering -[Jumio](https://www.jumio.com) is a high-assurance identity-verification platform — Document + Selfie + Face Match — common in regulated industries (financial services, healthcare, gambling, crypto). This integration starts a Jumio workflow from Ory Actions during registration, consumes Jumio's HMAC-signed callback when the workflow finishes, and gates login on the stored verification status. +[Jumio](https://www.jumio.com) is a high-assurance identity-verification platform — Document + Selfie + Face Match — common in regulated industries (financial services, healthcare, gambling, crypto). This integration starts a Jumio workflow from an Ory Action after registration, consumes Jumio's HMAC-signed callback when the workflow finishes, and gates login on the stored verification status. **Type:** webhook (Ory Actions over HTTP — code in [`webhook/`](./webhook/)) **Docs page:** [ory.com/docs/integrates-with/identity-verification/jumio](https://www.ory.com/docs/integrates-with/identity-verification/jumio) @@ -11,9 +11,11 @@ | Path | Trigger | Auth | Purpose | | --- | --- | --- | --- | -| `POST /jumio/initiate` | sync `registration.after` | `X-Webhook-Secret` | Starts a Jumio workflow with `customerInternalReference = `; writes the Jumio account/workflow IDs to `metadata_public`. | -| `POST /jumio/callback` | async, FROM Jumio | HMAC-SHA256 over raw body | Resolves the Kratos identity via `customerInternalReference`; PATCHes verdict (`PASSED` / `WARNING` / `FAILED`) onto the identity. | -| `POST /jumio/validate` | sync `login.after`, `can_interrupt: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.jumio.status == "rejected"`. | +| `POST /jumio/initiate` | `registration.after` without `response.parse`, after the identity is saved | `X-Webhook-Secret` | Starts a Jumio workflow with `customerInternalReference = `; writes the Jumio account ID and the status `initiated` to `metadata_public`. | +| `POST /jumio/callback` | async, FROM Jumio | HMAC-SHA256 over raw body | Resolves the identity via `customerInternalReference`; writes the workflow decision (`PASSED` / `WARNING` / `REJECTED`) to `metadata_public.jumio_verification_decision` and sets `metadata_public.jumio_verification_status`. | +| `POST /jumio/validate` | `login.after`, `response.parse: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.jumio_verification_status == "rejected"`. | + +The callback sets `jumio_verification_status` to `verified` for `PASSED` and to `rejected` for every other decision, including `WARNING`. Change that mapping in the handler if you want to send `WARNING` to manual review instead of blocking sign-in. ## Required env vars @@ -40,9 +42,17 @@ npm start # runs `tsx server.ts` ## Configure Ory -1. Use [`ory-actions.yaml`](./ory-actions.yaml) as the snippet for `selfservice.flows.{registration,login}.after.hooks`. -2. The body templates are in [`jsonnet/`](./jsonnet/). -3. The `/jumio/callback` URL must be publicly reachable; configure it as the workflow callback in **Jumio Customer Portal → Workflow Definition** with the matching `JUMIO_CALLBACK_SECRET`. +1. Merge [`ory-actions.yaml`](./ory-actions.yaml) into your identity configuration. It puts the hooks in the method-specific lists (`selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`). Both lists are aliased into the `oidc`, `code`, `passkey`, and `webauthn` lists, because Ory runs the flow-level `after.hooks` list only for a method that has no hooks of its own. Add any other method you enable, such as `saml`, the same way. Keep the `session` hook last in the registration lists. +2. The body templates are in [`jsonnet/`](./jsonnet/). On Ory Network, encode each template with `base64 < jsonnet/initiate.jsonnet | tr -d '\n'` and set `body: base64://`. +3. Keep `response.parse` off for `/jumio/initiate`. With `response.parse: true`, the action runs before the identity is saved and receives the ID `00000000-0000-0000-0000-000000000000`, so Jumio's `customerInternalReference` and the handler's Admin API update can't find the identity. +4. `/jumio/validate` blocks sign-in with `403` and this body, which Ory shows to the user: + + ```json + {"messages": [{"instance_ptr": "#/", "messages": [{"id": 9001001, "text": "Identity verification failed. Please contact support.", "type": "error"}]}]} + ``` + +5. Ory doesn't relay webhook responses to the browser. After sign-up, your application starts the Jumio capture (web client or SDK) for the workflow the handler created. +6. The `/jumio/callback` URL must be publicly reachable; configure it as the workflow callback in **Jumio Customer Portal → Workflow Definition** with the matching `JUMIO_CALLBACK_SECRET`. ## License diff --git a/identity-verification/jumio/jsonnet/initiate.jsonnet b/identity-verification/jumio/jsonnet/initiate.jsonnet index d1fc24c..089e9c7 100644 --- a/identity-verification/jumio/jsonnet/initiate.jsonnet +++ b/identity-verification/jumio/jsonnet/initiate.jsonnet @@ -1,11 +1,11 @@ -// Body for the sync post-registration Ory Action that starts a Jumio workflow. -function(ctx) { - kratos_identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "first") - then ctx.identity.traits.name.first - else "", - last_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "last") - then ctx.identity.traits.name.last - else "", -} +// Body for the post-registration Ory Action that starts a Jumio workflow. +// The action runs after the identity is saved, so ctx.identity.id is the real ID. +function(ctx) + local traits = std.get(ctx.identity, 'traits', {}); + local name = std.get(traits, 'name', {}); + { + kratos_identity_id: ctx.identity.id, + email: std.get(traits, 'email', null), + first_name: if name == null then '' else std.get(name, 'first', ''), + last_name: if name == null then '' else std.get(name, 'last', ''), + } diff --git a/identity-verification/jumio/jsonnet/validate.jsonnet b/identity-verification/jumio/jsonnet/validate.jsonnet index db26909..92ad522 100644 --- a/identity-verification/jumio/jsonnet/validate.jsonnet +++ b/identity-verification/jumio/jsonnet/validate.jsonnet @@ -1,13 +1,9 @@ -// Body for the sync post-login Ory Action that checks Jumio verification -// status before issuing a session. -function(ctx) { - kratos_identity_id: ctx.identity.id, - jumio_account_id: - if ctx.identity.metadata_public != null && std.objectHas(ctx.identity.metadata_public, "jumio_account_id") - then ctx.identity.metadata_public.jumio_account_id - else null, - jumio_verification_status: - if ctx.identity.metadata_public != null && std.objectHas(ctx.identity.metadata_public, "jumio_verification_status") - then ctx.identity.metadata_public.jumio_verification_status - else null, -} +// Body for the post-login Ory Action that checks the stored Jumio +// verification status before Ory issues a session. +function(ctx) + local metadata = std.get(ctx.identity, 'metadata_public', null); + { + kratos_identity_id: ctx.identity.id, + jumio_account_id: if metadata == null then null else std.get(metadata, 'jumio_account_id', null), + jumio_verification_status: if metadata == null then null else std.get(metadata, 'jumio_verification_status', null), + } diff --git a/identity-verification/jumio/ory-actions.yaml b/identity-verification/jumio/ory-actions.yaml index 6ab6abc..97fa94a 100644 --- a/identity-verification/jumio/ory-actions.yaml +++ b/identity-verification/jumio/ory-actions.yaml @@ -1,10 +1,22 @@ # Jumio identity-verification hooks. # -# Two Ory-side hooks: -# - post-registration → /jumio/initiate starts a Jumio workflow, writes -# the Jumio account ID into metadata. -# - post-login → /jumio/validate blocks the login when the stored -# verification status is rejected. +# Two Ory-side hooks: - post-registration → /jumio/initiate starts a Jumio +# workflow, writes the Jumio account ID into metadata. - post-login → +# /jumio/validate blocks the login when the stored verification status is +# rejected. +# +# Ory Kratos Identities runs the flow-level after.hooks list only for a method +# that has no hooks of its own, so the hooks go in the method lists. The +# password lists are aliased into the oidc, code, passkey, and webauthn lists, +# so every sign-in method runs the same hooks. Alias them into any other +# method you enable, such as saml, the same way. +# +# The initiate hook has no response.parse, so it runs after the identity is +# saved and receives the real identity ID. response.ignore makes it run in the +# background so the Jumio call doesn't delay sign-up. +# +# On Ory Network, replace each file:// body with +# base64://. # # Jumio also posts back to /jumio/callback when the workflow finishes; that # endpoint is gated by an HMAC-SHA256 signature in X-Jumio-Signature, not by @@ -14,37 +26,52 @@ selfservice: flows: registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/jumio/initiate - method: POST - body: file://./jsonnet/initiate.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/jumio/initiate + method: POST + body: file://./jsonnet/initiate.jsonnet + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/jumio/validate - method: POST - body: file://./jsonnet/validate.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/jumio/validate + method: POST + body: file://./jsonnet/validate.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/identity-verification/jumio/registry.entry.yaml b/identity-verification/jumio/registry.entry.yaml index 955e096..d103673 100644 --- a/identity-verification/jumio/registry.entry.yaml +++ b/identity-verification/jumio/registry.entry.yaml @@ -8,17 +8,17 @@ category: identity-verification type: webhook maintainedBy: Ory Engineering description: >- - Run Jumio identity verification (Document + Selfie + Face Match) from Ory Actions during registration; consume + Run Jumio identity verification (Document + Selfie + Face Match) from an Ory Action after registration; consume Jumio's HMAC-signed callback when the workflow finishes and gate login on the stored verification status. Common in regulated industries (financial services, healthcare, gambling, crypto) for high-assurance KYC. useCase: >- A regulated B2C product (fintech, crypto, online gambling, regulated healthcare) needs document + biometric KYC at signup with a vendor that's already on most regulators' approved lists. coreFunctionality: |- - - Three endpoints: POST /jumio/initiate (sync registration.after; starts workflow with customerInternalReference = Kratos identity id); POST /jumio/callback (async from Jumio; HMAC-SHA256 verified; PATCHes verdict to identity metadata); POST /jumio/validate (sync login.after with can_interrupt: true; blocks rejected identities). + - Three endpoints: POST /jumio/initiate (registration.after without response.parse, after the identity is saved; starts workflow with customerInternalReference = Ory identity id); POST /jumio/callback (async from Jumio; HMAC-SHA256 verified; writes the decision to metadata_public); POST /jumio/validate (login.after with response.parse; blocks rejected identities with a 403 and a messages body). - customerInternalReference is the binding between Jumio workflow and Kratos identity — set on initiate, consumed on callback. - - Identity-side state stored in metadata_public via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). - - Configured via Ory Actions on registration.after (sync) + login.after (sync, can_interrupt) — no Kratos schema changes required. + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity). + - Hooks go in the method-specific lists (after.password.hooks, after.oidc.hooks, ...) — no Kratos schema changes required. - SDK: @ory/client for Kratos admin API; native fetch for Jumio API. oryMechanism: - actions diff --git a/identity-verification/jumio/webhook/server.ts b/identity-verification/jumio/webhook/server.ts index 65dbcf0..09e8095 100644 --- a/identity-verification/jumio/webhook/server.ts +++ b/identity-verification/jumio/webhook/server.ts @@ -3,16 +3,17 @@ // Jumio <> Ory Actions webhook handler. // // Three endpoints: -// POST /jumio/initiate — sync post-registration; starts a Jumio workflow -// and writes the Jumio account ID to metadata_public. +// POST /jumio/initiate — post-registration, after the identity is saved; +// starts a Jumio workflow and writes the Jumio +// account ID to metadata_public. // POST /jumio/callback — async callback FROM Jumio when the workflow // finishes; HMAC-SHA256 signature verified. // Resolves the Kratos identity via Jumio's // customerInternalReference (we set this to the // Kratos identity id during initiate) and patches // the verification verdict onto the identity. -// POST /jumio/validate — sync post-login; blocks login when the stored -// verification status is `rejected`. +// POST /jumio/validate — sync post-login with response.parse; blocks login +// when the stored verification status is `rejected`. import "dotenv/config"; import crypto from "node:crypto"; @@ -21,7 +22,12 @@ import express, { type Request, type Response, } from "express"; -import { Configuration, IdentityApi, JsonPatchOpEnum } from "@ory/client"; +import { + Configuration, + IdentityApi, + type JsonPatch, + JsonPatchOpEnum, +} from "@ory/client"; const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; @@ -53,6 +59,79 @@ const kratos = new IdentityApi( new Configuration({ basePath: KRATOS_ADMIN_URL, accessToken: ORY_API_KEY }), ); +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +// Patch only this integration's keys. When a metadata parent is null, the +// updated_at test prevents replacing data written since the GET; a stale +// snapshot is fetched again before retrying. +async function mergeMetadata( + id: string, + update: { + metadata_public?: Record; + metadata_admin?: Record; + }, +): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const { data: identity } = await kratos.getIdentity({ id }); + const jsonPatch: JsonPatch[] = []; + for (const field of ["metadata_public", "metadata_admin"] as const) { + const values = update[field]; + if (!values) continue; + const entries = Object.entries(values).filter( + ([, value]) => value !== undefined, + ); + if (entries.length === 0) continue; + if (identity[field] == null) { + if (!identity.updated_at) + throw new Error("Identity is missing updated_at"); + jsonPatch.push({ + op: JsonPatchOpEnum.Test, + path: "/updated_at", + value: identity.updated_at, + }); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}`, + value: Object.fromEntries(entries), + }); + } else { + for (const [key, value] of entries) { + const escaped = key.replace(/~/g, "~0").replace(/\//g, "~1"); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}/${escaped}`, + value, + }); + } + } + } + if (jsonPatch.length === 0) return; + try { + await kratos.patchIdentity({ id, jsonPatch }); + return; + } catch (error) { + const response = ( + error as { response?: { status?: number; data?: { reason?: string } } } + ).response; + if ( + attempt === 2 || + response?.status !== 400 || + response.data?.reason !== "failed to apply JSON patch" + ) + throw error; + } + } +} + +// Ory Kratos Identities shows `text` to the user only for this response shape. +function block(res: Response, text: string): void { + res.status(403).json({ + messages: [ + { instance_ptr: "#/", messages: [{ id: 9001001, text, type: "error" }] }, + ], + }); +} + interface InitiateRequest { kratos_identity_id?: string; email?: string; @@ -119,7 +198,9 @@ async function getJumioToken(): Promise { if (cachedJumioToken && Date.now() < cachedJumioToken.expiresAt - 60_000) { return cachedJumioToken.token; } - const basic = Buffer.from(`${JUMIO_CLIENT_ID}:${JUMIO_CLIENT_SECRET}`).toString("base64"); + const basic = Buffer.from( + `${JUMIO_CLIENT_ID}:${JUMIO_CLIENT_SECRET}`, + ).toString("base64"); const res = await fetch(JUMIO_AUTH_URL as string, { method: "POST", headers: { @@ -147,13 +228,17 @@ app.get("/health", (_req, res) => { }); // Post-registration: create a Jumio account/workflow and record the IDs. +// Configure this action without response.parse so it runs after the identity +// is saved and receives the real identity ID. app.post( "/jumio/initiate", verifyWebhookSecret, async (req: Request, res: Response) => { const { kratos_identity_id, email } = req.body; - if (!kratos_identity_id || !email) { - res.status(400).json({ error: "kratos_identity_id and email are required" }); + if (!kratos_identity_id || kratos_identity_id === NIL_UUID || !email) { + res + .status(400) + .json({ error: "a saved kratos_identity_id and email are required" }); return; } @@ -178,45 +263,27 @@ app.post( const { id: accountId, token: transactionToken } = (await accountRes.json()) as JumioAccountCreateResponse; - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/jumio_account_id", value: accountId }, - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/jumio_verification_status", - value: "initiated", - }, - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/jumio_initiated_at", - value: new Date().toISOString(), - }, - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/jumio_transaction_token", - value: transactionToken, - }, - ], + await mergeMetadata(kratos_identity_id, { + metadata_public: { + jumio_account_id: accountId, + jumio_verification_status: "initiated", + jumio_initiated_at: new Date().toISOString(), + }, + metadata_admin: { jumio_transaction_token: transactionToken }, }); res.json({ success: true, jumio_account_id: accountId }); } catch (err) { console.error("Jumio initiate error:", (err as Error).message); - // Best-effort: record the error on the identity so support can see it. try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/jumio_initiation_error", - value: (err as Error).message, - }, - ], + await mergeMetadata(kratos_identity_id, { + metadata_admin: { jumio_initiation_error: (err as Error).message }, }); } catch (e) { - console.error("Failed to record initiation error:", (e as Error).message); + console.error( + "Failed to record initiation error:", + (e as Error).message, + ); } res.status(200).json({ success: false, error: (err as Error).message }); } @@ -256,16 +323,23 @@ app.post( // customerInternalReference is what we set to kratos_identity_id on initiate. const kratosIdentityId = req.body.account?.customerInternalReference; - // Acknowledge immediately so Jumio doesn't retry while we work. - res.json({ received: true }); - - if (workflowStatus !== "PROCESSED" || !workflowId || !accountId || !kratosIdentityId) { + if ( + workflowStatus !== "PROCESSED" || + !workflowId || + !accountId || + !kratosIdentityId + ) { console.log( `Jumio callback skipped: workflow=${workflowId} status=${workflowStatus} kratos=${kratosIdentityId}`, ); + res.json({ received: true, ignored: true }); return; } + // The callback is acknowledged only after the identity is updated, so a + // failed update is retried by Jumio instead of being lost. If this work can + // outlast Jumio's callback timeout, put a durable queue in front of it. + try { const accessToken = await getJumioToken(); const detailsRes = await fetch( @@ -281,88 +355,45 @@ app.post( } const details = (await detailsRes.json()) as JumioWorkflowResponse; const decisionType = details.decision?.type ?? "UNKNOWN"; + // Every decision other than PASSED, including WARNING, blocks sign-in. + // Map WARNING to a review status here to route it to manual review instead. const verified = decisionType === "PASSED"; - await kratos.patchIdentity({ - id: kratosIdentityId, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/jumio_verification_status", - value: verified ? "verified" : "rejected", - }, - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/jumio_verification_decision", - value: decisionType, - }, - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/jumio_verified_at", - value: req.body.callbackSentAt ?? new Date().toISOString(), - }, - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/jumio_workflow_id", - value: workflowId, - }, - ], + await mergeMetadata(kratosIdentityId, { + metadata_public: { + jumio_verification_status: verified ? "verified" : "rejected", + jumio_verification_decision: decisionType, + jumio_verified_at: + req.body.callbackSentAt ?? new Date().toISOString(), + }, + metadata_admin: { jumio_workflow_id: workflowId }, }); console.log( `Jumio callback: kratos=${kratosIdentityId} decision=${decisionType} verified=${verified}`, ); + res.json({ received: true }); } catch (err) { - console.error("Jumio callback processing failed:", (err as Error).message); + console.error( + "Jumio callback processing failed:", + (err as Error).message, + ); + res.status(500).json({ error: "processing_failed" }); } }, ); -// Post-login: block when verification is rejected; allow + warn when pending. +// Post-login: block when verification is rejected. Ory Kratos Identities +// ignores identity changes in a login.after response, so this only decides. app.post( "/jumio/validate", verifyWebhookSecret, - async (req: Request, res: Response) => { - const { kratos_identity_id, jumio_account_id, jumio_verification_status } = req.body; - if (!kratos_identity_id || !jumio_account_id) { - res.json({ success: true }); // no verification on file → allow - return; - } - - if (jumio_verification_status === "verified") { - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/jumio_last_validated_at", - value: new Date().toISOString(), - }, - ], - }); - } catch (err) { - console.warn("Could not update last_validated_at:", (err as Error).message); - } - res.json({ success: true }); - return; - } - - if (jumio_verification_status === "rejected") { - res.status(400).json({ - messages: [ - { - instance_ptr: "#/", - message: "Identity verification failed. Please contact support.", - type: "error", - }, - ], - }); + (req: Request, res: Response) => { + if (req.body.jumio_verification_status === "rejected") { + block(res, "Identity verification failed. Please contact support."); return; } - - // Pending — allow with a warning that the client can render. - res.json({ success: true, warning: "Please complete identity verification" }); + res.json({}); }, ); diff --git a/identity-verification/lexisnexis/README.md b/identity-verification/lexisnexis/README.md index a7892bf..85a27cd 100644 --- a/identity-verification/lexisnexis/README.md +++ b/identity-verification/lexisnexis/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Ory Engineering -[LexisNexis Risk Solutions](https://risk.lexisnexis.com) provides InstantID — a database-backed identity-verification API that scores name/address/SSN/DOB matches and returns NAS / NAP / CVI scores. Common in US financial-services and insurance flows where document scans are too high-friction. This integration runs InstantID synchronously from Ory Actions during registration and gates login on the stored verification status. +[LexisNexis Risk Solutions](https://risk.lexisnexis.com) provides InstantID — a database-backed identity-verification API that scores name/address/SSN/DOB matches and returns NAS / NAP / CVI scores. Common in US financial-services and insurance flows where document scans are too high-friction. This integration runs InstantID from an Ory Action before the identity is saved, rejects registrations that fail your thresholds, and gates login on the stored verification status. **Type:** webhook (Ory Actions over HTTP — code in [`webhook/`](./webhook/)) **Docs page:** [ory.com/docs/integrates-with/identity-verification/lexisnexis](https://www.ory.com/docs/integrates-with/identity-verification/lexisnexis) @@ -11,30 +11,32 @@ | Path | Trigger | Auth | Purpose | | --- | --- | --- | --- | -| `POST /lexisnexis/verify` | sync `registration.after`, `can_interrupt: true` | `X-Webhook-Secret` | Calls LexisNexis InstantID with the identity's PII; evaluates NAS/NAP/CVI against thresholds; PATCHes verdict (`passed` / `failed`) onto the identity; returns `{ status: "ok" }` or surfaces an error to fail the registration flow. | -| `POST /lexisnexis/validate` | sync `login.after`, `can_interrupt: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.lexisnexis.verification_status == "failed"`. | +| `POST /lexisnexis/verify` | `registration.after`, `response.parse: true`, before the identity is saved | `X-Webhook-Secret` | Calls LexisNexis InstantID with the identity's PII and evaluates NAS, NAP, and CVI against thresholds. On a pass, responds `200` with `{"identity": {"metadata_public": {"lexisnexis_verification_status": "verified", ...}}}`. On a fail, or when LexisNexis can't be reached, responds `403` with a `messages` body to reject the registration. | +| `POST /lexisnexis/validate` | `login.after`, `response.parse: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.lexisnexis_verification_status == "failed"`, for example after you mark an identity as failed through the Ory Admin API. | -## NAS / NAP / CVI +The verify action runs before the identity is saved, so `ctx.identity.id` is `00000000-0000-0000-0000-000000000000` and the identity isn't in the Admin API yet. The handler returns the result in the response body instead of patching the identity, and Ory saves the returned metadata with the new identity. Ory replaces `metadata_public` with the returned object, so the handler merges in the values the Jsonnet body sends. -LexisNexis returns three scores per inquiry; defaults below are 50 (calibrated for low-risk consumer flows; tune up for higher-risk products): +## NAS, NAP, and CVI -- **NAS** — Name + Address Score: how well the supplied name + address match LexisNexis records. -- **NAP** — Name + Phone Score: name + phone match. -- **CVI** — Comprehensive Verification Index: roll-up score across all signals. +LexisNexis returns three scores per inquiry. Compare each on its own scale, and choose thresholds with LexisNexis for your risk profile: + +- **NAS** — name, address, and SSN summary, from 0 to 12. 12 means the first name, last name, address, and SSN all matched. +- **NAP** — name, address, and phone summary, from 0 to 12. 12 means the first name, last name, address, and phone all matched. +- **CVI** — Comprehensive Verification Index, from 0 to 50 in steps of 10. 0 means nothing was verified, and 50 means full name, address, phone, and SSN were verified. + +The handler passes when InstantID's verification indicator is `Pass`, CVI meets its threshold, and NAS or NAP meets its threshold. A score of 0 means LexisNexis found no matching records, not that the person is fraudulent. ## Required env vars ``` -ORY_WEBHOOK_SECRET Bearer / X-Webhook-Secret value Ory sends -KRATOS_ADMIN_URL Ory Network admin URL (or self-hosted Kratos admin) -ORY_API_KEY Ory admin API token (identities:write) +ORY_WEBHOOK_SECRET X-Webhook-Secret value Ory sends LEXISNEXIS_USERNAME InstantID username LEXISNEXIS_PASSWORD InstantID password LEXISNEXIS_API_URL InstantID API URL LEXISNEXIS_ORG_ID InstantID organization id -INSTANTID_NAS_THRESHOLD default: 50 -INSTANTID_NAP_THRESHOLD default: 50 -INSTANTID_CVI_THRESHOLD default: 50 +INSTANTID_NAS_THRESHOLD minimum NAS, 0-12 +INSTANTID_NAP_THRESHOLD minimum NAP, 0-12 +INSTANTID_CVI_THRESHOLD minimum CVI, 0-50 PORT default: 3000 ``` @@ -47,6 +49,18 @@ npm install npm start # runs `tsx server.ts` ``` +## Configure Ory + +1. Merge [`ory-actions.yaml`](./ory-actions.yaml) into your identity configuration. It puts both actions in the method-specific lists (`selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`). Repeat them for every method you enable, such as `oidc` or `code`: Ory runs the flow-level `after.hooks` list only for a method that has no hooks of its own. Keep the `session` hook last in the registration lists. +2. The body templates are in [`jsonnet/`](./jsonnet/). On Ory Network, encode each template with `base64 < jsonnet/verify.jsonnet | tr -d '\n'` and set `body: base64://`. +3. Both endpoints block with `403` and this body, which Ory shows to the user: + + ```json + {"messages": [{"instance_ptr": "#/", "messages": [{"id": 9001001, "text": "We couldn't verify your identity.", "type": "error"}]}]} + ``` + +4. Ory waits about 30 seconds for the handler. The InstantID call is capped at 15 seconds. + ## License Apache-2.0. SPDX header in each source file. diff --git a/identity-verification/lexisnexis/jsonnet/validate.jsonnet b/identity-verification/lexisnexis/jsonnet/validate.jsonnet index c91b2fb..76e14f8 100644 --- a/identity-verification/lexisnexis/jsonnet/validate.jsonnet +++ b/identity-verification/lexisnexis/jsonnet/validate.jsonnet @@ -1,13 +1,8 @@ -// Body for the sync post-login Ory Action that reads stored verification +// Body for the post-login Ory Action that reads the stored verification // status and blocks login when it's "failed". -function(ctx) { - kratos_identity_id: ctx.identity.id, - lexisnexis_verification_status: - if ctx.identity.metadata_public != null && std.objectHas(ctx.identity.metadata_public, "lexisnexis_verification_status") - then ctx.identity.metadata_public.lexisnexis_verification_status - else null, - lexisnexis_cvi_score: - if ctx.identity.metadata_public != null && std.objectHas(ctx.identity.metadata_public, "lexisnexis_cvi_score") - then ctx.identity.metadata_public.lexisnexis_cvi_score - else null, -} +function(ctx) + local metadata = std.get(ctx.identity, 'metadata_public', null); + { + lexisnexis_verification_status: + if metadata == null then null else std.get(metadata, 'lexisnexis_verification_status', null), + } diff --git a/identity-verification/lexisnexis/jsonnet/verify.jsonnet b/identity-verification/lexisnexis/jsonnet/verify.jsonnet index 051627e..5855cfd 100644 --- a/identity-verification/lexisnexis/jsonnet/verify.jsonnet +++ b/identity-verification/lexisnexis/jsonnet/verify.jsonnet @@ -1,16 +1,16 @@ -// Body for the sync post-registration Ory Action that calls LexisNexis -// InstantID. Extend the identity schema with ssn/phone_number/address fields -// if your registration form collects them. -function(ctx) { - kratos_identity_id: ctx.identity.id, - first_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "first") - then ctx.identity.traits.name.first - else "", - last_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "last") - then ctx.identity.traits.name.last - else "", - address: if std.objectHas(ctx.identity.traits, "address") then ctx.identity.traits.address else {}, - date_of_birth: if std.objectHas(ctx.identity.traits, "date_of_birth") then ctx.identity.traits.date_of_birth else null, - ssn: if std.objectHas(ctx.identity.traits, "ssn") then ctx.identity.traits.ssn else null, - phone_number: if std.objectHas(ctx.identity.traits, "phone_number") then ctx.identity.traits.phone_number else null, -} +// Body for the post-registration Ory Action that calls LexisNexis InstantID. +// It runs before the identity is saved, so it sends no identity ID. Extend the +// identity schema with ssn/phone_number/address fields if your registration +// form collects them. +function(ctx) + local traits = std.get(ctx.identity, 'traits', {}); + local name = std.get(traits, 'name', {}); + { + metadata_public: std.get(ctx.identity, 'metadata_public', null), + first_name: if name == null then '' else std.get(name, 'first', ''), + last_name: if name == null then '' else std.get(name, 'last', ''), + address: std.get(traits, 'address', {}), + date_of_birth: std.get(traits, 'date_of_birth', null), + ssn: std.get(traits, 'ssn', null), + phone_number: std.get(traits, 'phone_number', null), + } diff --git a/identity-verification/lexisnexis/ory-actions.yaml b/identity-verification/lexisnexis/ory-actions.yaml index b83fe0e..a4c0c08 100644 --- a/identity-verification/lexisnexis/ory-actions.yaml +++ b/identity-verification/lexisnexis/ory-actions.yaml @@ -1,40 +1,68 @@ # LexisNexis InstantID identity-verification hooks. +# +# Ory Kratos Identities runs the flow-level after.hooks list only for a method +# that has no hooks of its own, so the hooks go in the method lists. The +# password lists are aliased into the oidc, code, passkey, and webauthn lists, +# so every sign-in method runs the same hooks. Alias them into any other +# method you enable, such as saml, the same way. +# +# The verify hook uses response.parse, so it runs before the identity is saved +# and can reject the registration. The handler returns the result in the +# response, and Ory saves it with the new identity. +# +# On Ory Network, replace each file:// body with +# base64://. selfservice: flows: registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/lexisnexis/verify - method: POST - body: file://./jsonnet/verify.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/lexisnexis/verify + method: POST + body: file://./jsonnet/verify.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/lexisnexis/validate - method: POST - body: file://./jsonnet/validate.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/lexisnexis/validate + method: POST + body: file://./jsonnet/validate.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/identity-verification/lexisnexis/registry.entry.yaml b/identity-verification/lexisnexis/registry.entry.yaml index e690a07..f5a079a 100644 --- a/identity-verification/lexisnexis/registry.entry.yaml +++ b/identity-verification/lexisnexis/registry.entry.yaml @@ -8,7 +8,7 @@ category: identity-verification type: webhook maintainedBy: Ory Engineering description: >- - Run LexisNexis Risk Solutions InstantID identity verification from Ory Actions during registration. Database- + Run LexisNexis Risk Solutions InstantID identity verification from an Ory Action during registration. Database- backed scoring (NAS / NAP / CVI) of name/address/phone/SSN/DOB matches — common in US financial services and insurance where document scans are too high-friction. Gates login on the stored verification status. useCase: >- @@ -16,11 +16,11 @@ useCase: >- signup without document upload, scoring against authoritative US data — and wants the threshold per check configurable per product risk profile. coreFunctionality: |- - - Two endpoints: POST /lexisnexis/verify (sync registration.after with can_interrupt; calls InstantID, evaluates NAS/NAP/CVI vs thresholds, PATCHes verdict to identity); POST /lexisnexis/validate (sync login.after with can_interrupt; blocks identities with failed status). - - NAS / NAP / CVI scores returned per inquiry — defaults 50 (calibrated for low-risk consumer flows; tune up for higher risk). - - Identity-side state stored in metadata_public via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Two endpoints: POST /lexisnexis/verify (registration.after with response.parse, before the identity is saved; calls InstantID, evaluates NAS/NAP/CVI vs thresholds, returns the verdict in the response or rejects the registration); POST /lexisnexis/validate (login.after with response.parse; blocks identities with failed status). + - NAS and NAP range from 0 to 12, CVI from 0 to 50; thresholds are required env vars agreed with LexisNexis. + - Identity-side state returned in the webhook response as metadata_public, saved by Ory with the new identity. - Synchronous-only — no async callback path (InstantID returns its decision in the API response). - - SDK: @ory/client for Kratos admin API; native fetch for LexisNexis API. + - Native fetch for the LexisNexis API; no Ory Admin API calls. oryMechanism: - actions protocol: diff --git a/identity-verification/lexisnexis/webhook/.env.example b/identity-verification/lexisnexis/webhook/.env.example index 8ffefba..f06aed0 100644 --- a/identity-verification/lexisnexis/webhook/.env.example +++ b/identity-verification/lexisnexis/webhook/.env.example @@ -1,21 +1,18 @@ # Shared secret with Ory's webhook configuration (X-Webhook-Secret header value). ORY_WEBHOOK_SECRET= -# Ory Network admin URL and admin API key (identity read/write scope). -KRATOS_ADMIN_URL= -ORY_API_KEY= - # LexisNexis InstantID Basic-auth credentials + tenant config. LEXISNEXIS_USERNAME= LEXISNEXIS_PASSWORD= LEXISNEXIS_API_URL= LEXISNEXIS_ORG_ID= -# Thresholds applied to the score response. NAS/NAP are 0-999 in the API; -# normalized to 0-100 before threshold comparison. CVI is 0-100 directly. -INSTANTID_NAS_THRESHOLD=50 -INSTANTID_NAP_THRESHOLD=50 -INSTANTID_CVI_THRESHOLD=50 +# Required. Minimum scores to pass, on the scales InstantID returns: +# NAS and NAP summaries are 0-12, CVI is 0-50. Set them from your +# LexisNexis risk policy; the handler exits when any is missing. +INSTANTID_NAS_THRESHOLD= +INSTANTID_NAP_THRESHOLD= +INSTANTID_CVI_THRESHOLD= # Port the handler listens on. PORT=3000 diff --git a/identity-verification/lexisnexis/webhook/server.ts b/identity-verification/lexisnexis/webhook/server.ts index e7f9456..d2d524d 100644 --- a/identity-verification/lexisnexis/webhook/server.ts +++ b/identity-verification/lexisnexis/webhook/server.ts @@ -3,12 +3,13 @@ // LexisNexis Risk Solutions <> Ory Actions webhook handler. // // Two endpoints: -// POST /lexisnexis/verify — sync post-registration. Calls LexisNexis -// InstantID, evaluates NAS/NAP/CVI scores, -// patches verification result onto the -// Kratos identity via the Admin API. +// POST /lexisnexis/verify — sync post-registration with response.parse, +// before the identity is saved. Calls +// LexisNexis InstantID, evaluates NAS/NAP/CVI +// scores, and returns the result in the +// response for Ory to save with the identity. // POST /lexisnexis/validate — sync post-login. Blocks login when the -// stored verification_status is "failed". +// stored verification status is "failed". import "dotenv/config"; import crypto from "node:crypto"; @@ -17,44 +18,47 @@ import express, { type Request, type Response, } from "express"; -import { Configuration, IdentityApi, JsonPatchOpEnum } from "@ory/client"; const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; -const KRATOS_ADMIN_URL = process.env.KRATOS_ADMIN_URL; -const ORY_API_KEY = process.env.ORY_API_KEY; const LN_USERNAME = process.env.LEXISNEXIS_USERNAME; const LN_PASSWORD = process.env.LEXISNEXIS_PASSWORD; const LN_API_URL = process.env.LEXISNEXIS_API_URL; const LN_ORG_ID = process.env.LEXISNEXIS_ORG_ID; -const NAS_THRESHOLD = Number(process.env.INSTANTID_NAS_THRESHOLD ?? "50"); -const NAP_THRESHOLD = Number(process.env.INSTANTID_NAP_THRESHOLD ?? "50"); -const CVI_THRESHOLD = Number(process.env.INSTANTID_CVI_THRESHOLD ?? "50"); - -if ( - !ORY_WEBHOOK_SECRET || - !KRATOS_ADMIN_URL || - !ORY_API_KEY || - !LN_USERNAME || - !LN_PASSWORD || - !LN_API_URL || - !LN_ORG_ID -) { +// NAS and NAP range from 0 to 12, CVI from 0 to 50 in steps of 10. Agree on +// thresholds with LexisNexis for your risk profile. +function threshold(name: string, max: number): number { + const value = Number(process.env[name]); + if (process.env[name] === undefined || !Number.isFinite(value) || value < 0 || value > max) { + console.error(`${name} must be a number between 0 and ${max}.`); + process.exit(1); + } + return value; +} + +if (!ORY_WEBHOOK_SECRET || !LN_USERNAME || !LN_PASSWORD || !LN_API_URL || !LN_ORG_ID) { console.error( - "Missing required env vars. See .env.example. Required: ORY_WEBHOOK_SECRET, KRATOS_ADMIN_URL, ORY_API_KEY, LEXISNEXIS_USERNAME, LEXISNEXIS_PASSWORD, LEXISNEXIS_API_URL, LEXISNEXIS_ORG_ID.", + "Missing required env vars. See .env.example. Required: ORY_WEBHOOK_SECRET, LEXISNEXIS_USERNAME, LEXISNEXIS_PASSWORD, LEXISNEXIS_API_URL, LEXISNEXIS_ORG_ID, INSTANTID_NAS_THRESHOLD, INSTANTID_NAP_THRESHOLD, INSTANTID_CVI_THRESHOLD.", ); process.exit(1); } -const kratos = new IdentityApi( - new Configuration({ basePath: KRATOS_ADMIN_URL, accessToken: ORY_API_KEY }), -); +const NAS_THRESHOLD = threshold("INSTANTID_NAS_THRESHOLD", 12); +const NAP_THRESHOLD = threshold("INSTANTID_NAP_THRESHOLD", 12); +const CVI_THRESHOLD = threshold("INSTANTID_CVI_THRESHOLD", 50); + +// Ory Kratos Identities shows `text` to the user only for this response shape. +function block(res: Response, text: string): void { + res.status(403).json({ + messages: [{ instance_ptr: "#/", messages: [{ id: 9001001, text, type: "error" }] }], + }); +} const LN_AUTH = `Basic ${Buffer.from(`${LN_USERNAME}:${LN_PASSWORD}`).toString("base64")}`; interface VerifyRequest { - kratos_identity_id?: string; + metadata_public?: Record | null; first_name?: string; last_name?: string; address?: { street?: string; city?: string; state?: string; postal_code?: string }; @@ -64,9 +68,7 @@ interface VerifyRequest { } interface ValidateRequest { - kratos_identity_id?: string; lexisnexis_verification_status?: string; - lexisnexis_cvi_score?: number; } interface InstantIDResult { @@ -138,14 +140,10 @@ async function callInstantID(req: VerifyRequest): Promise { } function isVerified(r: InstantIDResult): boolean { - // NAS/NAP are 0–999 scale, normalize to 0–100 for threshold comparison. - const nas = (r.nameAddressSSNSummary / 999) * 100; - const nap = (r.nameAddressPhoneSummary / 999) * 100; - const cvi = r.comprehensiveVerification; return ( r.verificationIndicator === "Pass" && - cvi >= CVI_THRESHOLD && - (nas >= NAS_THRESHOLD || nap >= NAP_THRESHOLD) + r.comprehensiveVerification >= CVI_THRESHOLD && + (r.nameAddressSSNSummary >= NAS_THRESHOLD || r.nameAddressPhoneSummary >= NAP_THRESHOLD) ); } @@ -153,99 +151,55 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); +// Runs before the identity is saved, so the identity ID is the nil UUID and +// the identity isn't in the Admin API yet. The result goes back in the +// response body, and Ory saves it with the new identity. Ory replaces +// metadata_public with the returned object, so the existing values are merged in. app.post( "/lexisnexis/verify", verifyWebhookSecret, async (req: Request, res: Response) => { - const { kratos_identity_id } = req.body; - if (!kratos_identity_id) { - res.status(400).json({ error: "kratos_identity_id required" }); - return; - } - + let result: InstantIDResult; try { - const result = await callInstantID(req.body); - const status = isVerified(result) ? "verified" : "failed"; - - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/lexisnexis_verification_status", value: status }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/lexisnexis_cvi_score", value: result.comprehensiveVerification }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/lexisnexis_verified_at", value: new Date().toISOString() }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/lexisnexis_nas_score", value: result.nameAddressSSNSummary }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/lexisnexis_nap_score", value: result.nameAddressPhoneSummary }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/lexisnexis_verification_indicator", value: result.verificationIndicator }, - ], - }); - - res.json({ success: true, verification_status: status }); + result = await callInstantID(req.body); } catch (err) { console.error("LexisNexis verify error:", (err as Error).message); - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/lexisnexis_verification_error", - value: (err as Error).message, - }, - ], - }); - } catch (e) { - console.error("Failed to record verification error:", (e as Error).message); - } - res.status(200).json({ success: false, error: (err as Error).message }); + block(res, "We couldn't verify your identity right now. Please try again later."); + return; } + + if (!isVerified(result)) { + console.log( + `LexisNexis verify failed: indicator=${result.verificationIndicator} nas=${result.nameAddressSSNSummary} nap=${result.nameAddressPhoneSummary} cvi=${result.comprehensiveVerification}`, + ); + block(res, "We couldn't verify your identity."); + return; + } + + res.json({ + identity: { + metadata_public: { + ...(req.body.metadata_public ?? {}), + lexisnexis_verification_status: "verified", + lexisnexis_cvi_score: result.comprehensiveVerification, + lexisnexis_verified_at: new Date().toISOString(), + }, + }, + }); }, ); +// Ory Kratos Identities ignores identity changes in a login.after response, +// so this only decides. app.post( "/lexisnexis/validate", verifyWebhookSecret, - async (req: Request, res: Response) => { - const { kratos_identity_id, lexisnexis_verification_status, lexisnexis_cvi_score } = req.body; - if (!kratos_identity_id || !lexisnexis_verification_status) { - res.json({ success: true }); // no verification on file → allow + (req: Request, res: Response) => { + if (req.body.lexisnexis_verification_status === "failed") { + block(res, "Identity verification failed. Please contact support to verify your account."); return; } - - if (lexisnexis_verification_status === "verified") { - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/lexisnexis_last_validated_at", - value: new Date().toISOString(), - }, - ], - }); - } catch (err) { - console.warn("Could not update last_validated_at:", (err as Error).message); - } - res.json({ success: true }); - return; - } - - if (lexisnexis_verification_status === "failed") { - res.status(400).json({ - messages: [ - { - instance_ptr: "#/", - message: "Identity verification failed. Please contact support to verify your account.", - type: "error", - context: { reason: "identity_verification_failed", cvi_score: lexisnexis_cvi_score }, - }, - ], - }); - return; - } - - console.warn(`Unknown LexisNexis verification status: ${lexisnexis_verification_status}`); - res.json({ success: true }); + res.json({}); }, ); diff --git a/identity-verification/onfido/README.md b/identity-verification/onfido/README.md index 88dac32..778b98e 100644 --- a/identity-verification/onfido/README.md +++ b/identity-verification/onfido/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Ory Engineering -[Onfido](https://onfido.com) is a global identity-verification platform — document, biometric, motion, and database checks composed via configurable workflows. This integration creates an Onfido applicant + workflow run from Ory Actions during registration, consumes Onfido's HMAC-signed callback when the workflow finishes, and gates login on the stored verification status. +[Onfido](https://onfido.com) is a global identity-verification platform — document, biometric, motion, and database checks composed via configurable workflows. This integration creates an Onfido applicant + workflow run after registration, consumes Onfido's HMAC-signed callback when the workflow finishes, and gates login on the stored verification status. **Type:** webhook (Ory Actions over HTTP — code in [`webhook/`](./webhook/)) **Docs page:** [ory.com/docs/integrates-with/identity-verification/onfido](https://www.ory.com/docs/integrates-with/identity-verification/onfido) @@ -11,14 +11,14 @@ | Path | Trigger | Auth | Purpose | | --- | --- | --- | --- | -| `POST /onfido/initiate` | sync `registration.after` | `X-Webhook-Secret` | Creates an Onfido applicant + workflow run with `custom_data.kratos_identity_id`; returns the SDK token to the client; writes the Onfido IDs to `metadata_public`. | -| `POST /onfido/callback` | async, FROM Onfido | `X-SHA2-Signature` (HMAC-SHA256 of raw body) | Resolves the Kratos identity via `custom_data.kratos_identity_id`; PATCHes status (`approved` / `review` / `declined`) onto the identity. | -| `POST /onfido/validate` | sync `login.after`, `can_interrupt: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.onfido.status == "declined"`. | +| `POST /onfido/initiate` | your backend after sign-up | `X-Webhook-Secret` | Creates an Onfido applicant and workflow run with `custom_data.kratos_identity_id`; writes the Onfido IDs to `metadata_public`; returns the SDK token to the caller. | +| `POST /onfido/callback` | async, FROM Onfido | `X-SHA2-Signature` (HMAC-SHA256 of raw body) | Resolves the identity via `custom_data.kratos_identity_id`; writes the workflow run status (`approved` / `review` / `declined`) to `metadata_public.onfido_verification_status`. | +| `POST /onfido/validate` | `login.after`, `response.parse: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.onfido_verification_status == "declined"`. | ## Required env vars ``` -ORY_WEBHOOK_SECRET Bearer / X-Webhook-Secret value Ory sends +ORY_WEBHOOK_SECRET X-Webhook-Secret value Ory and your backend send KRATOS_ADMIN_URL Ory Network admin URL (or self-hosted Kratos admin) ORY_API_KEY Ory admin API token (identities:write) ONFIDO_API_TOKEN Onfido API token @@ -39,10 +39,16 @@ npm start # runs `tsx server.ts` ## Configure Ory -1. Use [`ory-actions.yaml`](./ory-actions.yaml) as the snippet for `selfservice.flows.{registration,login}.after.hooks`. -2. The body templates are in [`jsonnet/`](./jsonnet/). -3. The `/onfido/callback` URL must be publicly reachable; configure it under **Onfido Dashboard → Webhooks** with the matching `ONFIDO_WEBHOOK_TOKEN`. -4. The Onfido SDK token returned by `/onfido/initiate` is short-lived — use it to render the verification UI immediately. +1. Call `/onfido/initiate` from your backend after sign-up, with the `X-Webhook-Secret` header and a JSON body of `kratos_identity_id`, `first_name`, `last_name`, and optionally `email` and `date_of_birth`, taken from the Ory session. Render the Onfido SDK with the returned `sdk_token`. Ory doesn't relay webhook responses to the browser, so start this step from your application. The SDK token is short-lived; use it immediately. +2. Merge [`ory-actions.yaml`](./ory-actions.yaml) into your identity configuration. It puts `/onfido/validate` in `selfservice.flows.login.after.password.hooks` with `response.parse: true`. Repeat it for every method you enable, such as `oidc` or `code`: Ory runs the flow-level `after.hooks` list only for a method that has no hooks of its own. +3. The body template is in [`jsonnet/`](./jsonnet/). On Ory Network, encode it with `base64 < jsonnet/validate.jsonnet | tr -d '\n'` and set `body: base64://`. +4. `/onfido/validate` blocks sign-in with `403` and this body, which Ory shows to the user: + + ```json + {"messages": [{"instance_ptr": "#/", "messages": [{"id": 9001001, "text": "Identity verification failed. Please contact support.", "type": "error"}]}]} + ``` + +5. The `/onfido/callback` URL must be publicly reachable; configure it under **Onfido Dashboard → Webhooks** with the matching `ONFIDO_WEBHOOK_TOKEN`. ## License diff --git a/identity-verification/onfido/jsonnet/initiate.jsonnet b/identity-verification/onfido/jsonnet/initiate.jsonnet deleted file mode 100644 index 3e43ca6..0000000 --- a/identity-verification/onfido/jsonnet/initiate.jsonnet +++ /dev/null @@ -1,10 +0,0 @@ -// Body for the sync post-registration Ory Action that starts an Onfido workflow. -function(ctx) { - kratos_identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "first") - then ctx.identity.traits.name.first else "", - last_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "last") - then ctx.identity.traits.name.last else "", - date_of_birth: if std.objectHas(ctx.identity.traits, "date_of_birth") then ctx.identity.traits.date_of_birth else null, -} diff --git a/identity-verification/onfido/jsonnet/validate.jsonnet b/identity-verification/onfido/jsonnet/validate.jsonnet index 01cc766..2a09d73 100644 --- a/identity-verification/onfido/jsonnet/validate.jsonnet +++ b/identity-verification/onfido/jsonnet/validate.jsonnet @@ -1,9 +1,9 @@ -// Body for the sync post-login Ory Action that reads the stored Onfido +// Body for the post-login Ory Action that reads the stored Onfido // verification status and blocks login when it's "declined". -function(ctx) { - kratos_identity_id: ctx.identity.id, - onfido_verification_status: - if ctx.identity.metadata_public != null && std.objectHas(ctx.identity.metadata_public, "onfido_verification_status") - then ctx.identity.metadata_public.onfido_verification_status - else null, -} +function(ctx) + local metadata = std.get(ctx.identity, 'metadata_public', null); + { + kratos_identity_id: ctx.identity.id, + onfido_verification_status: + if metadata == null then null else std.get(metadata, 'onfido_verification_status', null), + } diff --git a/identity-verification/onfido/ory-actions.yaml b/identity-verification/onfido/ory-actions.yaml index 083ceeb..91201b6 100644 --- a/identity-verification/onfido/ory-actions.yaml +++ b/identity-verification/onfido/ory-actions.yaml @@ -1,44 +1,44 @@ -# Onfido identity-verification hooks. +# Onfido identity-verification hook. # -# Two Ory-side hooks (initiate + validate). Onfido also POSTs back to +# Only the login check is an Ory Action. Ory doesn't relay webhook responses +# to the browser, so your backend calls /onfido/initiate after sign-up and +# renders the Onfido SDK with the returned token. Onfido also POSTs back to # /onfido/callback when the workflow completes; that endpoint is gated by # Onfido's HMAC-SHA256 signature, not by this Ory webhook secret. +# +# Ory Kratos Identities runs the flow-level after.hooks list only for a method +# that has no hooks of its own, so the hook goes in the method lists. The +# password lists are aliased into the oidc, code, passkey, and webauthn lists, +# so every sign-in method runs the same hooks. Alias them into any other +# method you enable, such as saml, the same way. +# +# On Ory Network, replace the file:// body with +# base64://. selfservice: flows: - registration: - after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/onfido/initiate - method: POST - body: file://./jsonnet/initiate.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/onfido/validate - method: POST - body: file://./jsonnet/validate.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/onfido/validate + method: POST + body: file://./jsonnet/validate.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/identity-verification/onfido/registry.entry.yaml b/identity-verification/onfido/registry.entry.yaml index 9d74830..240638c 100644 --- a/identity-verification/onfido/registry.entry.yaml +++ b/identity-verification/onfido/registry.entry.yaml @@ -9,17 +9,17 @@ type: webhook maintainedBy: Ory Engineering description: >- Run Onfido identity verification (document, biometric, motion, database checks composed via configurable - workflows) from Ory Actions during registration; consume Onfido's HMAC-signed callback when the workflow + workflows) after registration; consume Onfido's HMAC-signed callback when the workflow finishes and gate login on the stored verification status. useCase: >- A B2C product needs identity verification at signup with global coverage (especially EU + UK) and the flexibility to compose document, selfie, motion, and database checks per workflow without per-region code. coreFunctionality: |- - - Three endpoints: POST /onfido/initiate (sync registration.after; creates applicant + workflow run with custom_data.kratos_identity_id; returns SDK token); POST /onfido/callback (async from Onfido; X-SHA2-Signature HMAC-SHA256; PATCHes verdict to identity metadata); POST /onfido/validate (sync login.after with can_interrupt: true; blocks declined identities). + - Three endpoints: POST /onfido/initiate (called by the application backend after sign-up; creates applicant + workflow run with custom_data.kratos_identity_id; returns SDK token); POST /onfido/callback (async from Onfido; X-SHA2-Signature HMAC-SHA256; writes the status to metadata_public); POST /onfido/validate (login.after with response.parse; blocks declined identities with a 403 and a messages body). - custom_data.kratos_identity_id is the binding between Onfido workflow run and Kratos identity — set on initiate, consumed on callback. - Region-aware (ONFIDO_REGION = EU/US/CA) — selects the right API base host for data residency. - Onfido SDK token returned by initiate is short-lived; client must render the verification UI immediately. - - Identity-side state stored in metadata_public via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity). oryMechanism: - actions protocol: diff --git a/identity-verification/onfido/webhook/server.ts b/identity-verification/onfido/webhook/server.ts index 2e2560c..4cd0f7f 100644 --- a/identity-verification/onfido/webhook/server.ts +++ b/identity-verification/onfido/webhook/server.ts @@ -3,16 +3,16 @@ // Onfido <> Ory Actions webhook handler. // // Three endpoints: -// POST /onfido/initiate — sync post-registration. Creates an Onfido -// applicant + workflow run, returns the SDK -// token to the client, and writes IDs into -// metadata_public. +// POST /onfido/initiate — called by your backend after sign-up. Creates +// an Onfido applicant + workflow run, writes IDs +// into metadata_public, and returns the SDK token +// to the caller. // POST /onfido/callback — async webhook FROM Onfido. HMAC-SHA256 of the // raw body via X-SHA2-Signature; resolves the // Kratos identity from custom_data.kratos_identity_id // we set on initiate. -// POST /onfido/validate — sync post-login. Blocks login when stored -// status is "declined". +// POST /onfido/validate — sync post-login Ory Action. Blocks login when +// the stored status is "declined". import "dotenv/config"; import crypto from "node:crypto"; @@ -21,7 +21,12 @@ import express, { type Request, type Response, } from "express"; -import { Configuration, IdentityApi, JsonPatchOpEnum } from "@ory/client"; +import { + Configuration, + IdentityApi, + type JsonPatch, + JsonPatchOpEnum, +} from "@ory/client"; const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; @@ -56,6 +61,79 @@ const kratos = new IdentityApi( new Configuration({ basePath: KRATOS_ADMIN_URL, accessToken: ORY_API_KEY }), ); +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +// Patch only this integration's keys. When a metadata parent is null, the +// updated_at test prevents replacing data written since the GET; a stale +// snapshot is fetched again before retrying. +async function mergeMetadata( + id: string, + update: { + metadata_public?: Record; + metadata_admin?: Record; + }, +): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const { data: identity } = await kratos.getIdentity({ id }); + const jsonPatch: JsonPatch[] = []; + for (const field of ["metadata_public", "metadata_admin"] as const) { + const values = update[field]; + if (!values) continue; + const entries = Object.entries(values).filter( + ([, value]) => value !== undefined, + ); + if (entries.length === 0) continue; + if (identity[field] == null) { + if (!identity.updated_at) + throw new Error("Identity is missing updated_at"); + jsonPatch.push({ + op: JsonPatchOpEnum.Test, + path: "/updated_at", + value: identity.updated_at, + }); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}`, + value: Object.fromEntries(entries), + }); + } else { + for (const [key, value] of entries) { + const escaped = key.replace(/~/g, "~0").replace(/\//g, "~1"); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}/${escaped}`, + value, + }); + } + } + } + if (jsonPatch.length === 0) return; + try { + await kratos.patchIdentity({ id, jsonPatch }); + return; + } catch (error) { + const response = ( + error as { response?: { status?: number; data?: { reason?: string } } } + ).response; + if ( + attempt === 2 || + response?.status !== 400 || + response.data?.reason !== "failed to apply JSON patch" + ) + throw error; + } + } +} + +// Ory Kratos Identities shows `text` to the user only for this response shape. +function block(res: Response, text: string): void { + res.status(403).json({ + messages: [ + { instance_ptr: "#/", messages: [{ id: 9001001, text, type: "error" }] }, + ], + }); +} + interface InitiateRequest { kratos_identity_id?: string; first_name?: string; @@ -143,74 +221,78 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); +// Your backend calls this after sign-up with the identity ID and traits from +// the Ory session, then renders the Onfido SDK with the returned sdk_token. app.post( "/onfido/initiate", verifyWebhookSecret, async (req: Request, res: Response) => { - const { kratos_identity_id, first_name, last_name, email, date_of_birth } = req.body; - if (!kratos_identity_id || !first_name || !last_name) { - res.status(400).json({ error: "kratos_identity_id, first_name, last_name are required" }); + const { kratos_identity_id, first_name, last_name, email, date_of_birth } = + req.body; + if ( + !kratos_identity_id || + kratos_identity_id === NIL_UUID || + !first_name || + !last_name + ) { + res + .status(400) + .json({ + error: + "a saved kratos_identity_id, first_name, and last_name are required", + }); return; } try { - const applicant = await onfidoFetch("POST", "/applicants", { - first_name, - last_name, - ...(email && { email }), - ...(date_of_birth && { dob: date_of_birth }), - }); + const applicant = await onfidoFetch( + "POST", + "/applicants", + { + first_name, + last_name, + ...(email && { email }), + ...(date_of_birth && { dob: date_of_birth }), + }, + ); - const workflowRun = await onfidoFetch("POST", "/workflow_runs", { - workflow_id: ONFIDO_WORKFLOW_ID, - applicant_id: applicant.id, - custom_data: { kratos_identity_id }, - }); + const workflowRun = await onfidoFetch( + "POST", + "/workflow_runs", + { + workflow_id: ONFIDO_WORKFLOW_ID, + applicant_id: applicant.id, + custom_data: { kratos_identity_id }, + }, + ); - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/onfido_verification_status", value: "initiated" }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/onfido_applicant_id", value: applicant.id }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/onfido_workflow_run_id", value: workflowRun.id }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/onfido_initiated_at", value: new Date().toISOString() }, - ...(workflowRun.sdk_token - ? [{ op: JsonPatchOpEnum.Replace, path: "/metadata_admin/onfido_sdk_token", value: workflowRun.sdk_token }] - : []), - ...(workflowRun.dashboard_url - ? [{ op: JsonPatchOpEnum.Replace, path: "/metadata_admin/onfido_dashboard_url", value: workflowRun.dashboard_url }] - : []), - ], + await mergeMetadata(kratos_identity_id, { + metadata_public: { + onfido_verification_status: "initiated", + onfido_applicant_id: applicant.id, + onfido_workflow_run_id: workflowRun.id, + onfido_initiated_at: new Date().toISOString(), + }, + ...(workflowRun.dashboard_url && { + metadata_admin: { onfido_dashboard_url: workflowRun.dashboard_url }, + }), }); res.json({ - success: true, sdk_token: workflowRun.sdk_token, workflow_run_id: workflowRun.id, }); } catch (err) { console.error("Onfido initiate error:", (err as Error).message); - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/onfido_initiation_error", - value: (err as Error).message, - }, - ], - }); - } catch (e) { - console.error("Failed to record initiation error:", (e as Error).message); - } - res.status(200).json({ success: false, error: (err as Error).message }); + res.status(502).json({ error: "onfido_initiate_failed" }); } }, ); // Async callback FROM Onfido. HMAC-SHA256 over the RAW body (not a -// re-serialized JSON copy) using the configured webhook token. +// re-serialized JSON copy) using the configured webhook token. The handler +// answers 2xx only after the identity is updated, so Onfido retries a delivery +// that failed to apply. app.post( "/onfido/callback", async (req: Request, res: Response) => { @@ -236,77 +318,62 @@ app.post( } const { resource_type, action, object } = req.body.payload ?? {}; - res.json({ received: true }); - - if (resource_type !== "workflow_run" || action !== "workflow_run.completed" || !object?.id) { + if ( + resource_type !== "workflow_run" || + action !== "workflow_run.completed" || + !object?.id + ) { + res.json({ received: true, ignored: true }); return; } const workflowRunId = object.id; try { - const wr = await onfidoFetch("GET", `/workflow_runs/${workflowRunId}`); + const wr = await onfidoFetch( + "GET", + `/workflow_runs/${workflowRunId}`, + ); const kratosIdentityId = wr.custom_data?.kratos_identity_id; if (!kratosIdentityId) { - console.error(`Onfido workflow_run ${workflowRunId} missing custom_data.kratos_identity_id`); + console.error( + `Onfido workflow_run ${workflowRunId} missing custom_data.kratos_identity_id`, + ); + res.json({ received: true, ignored: true }); return; } - await kratos.patchIdentity({ - id: kratosIdentityId, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/onfido_verification_status", value: wr.status ?? "unknown" }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/onfido_completed_at", value: object.completed_at_iso8601 ?? new Date().toISOString() }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/onfido_output", value: JSON.stringify(wr.output ?? null) }, - ], + await mergeMetadata(kratosIdentityId, { + metadata_public: { + onfido_verification_status: wr.status ?? "unknown", + onfido_completed_at: + object.completed_at_iso8601 ?? new Date().toISOString(), + }, + metadata_admin: { onfido_output: JSON.stringify(wr.output ?? null) }, }); - console.log(`Onfido callback: kratos=${kratosIdentityId} status=${wr.status}`); + console.log( + `Onfido callback: kratos=${kratosIdentityId} status=${wr.status}`, + ); + res.json({ received: true }); } catch (err) { - console.error("Onfido callback processing failed:", (err as Error).message); + console.error( + "Onfido callback processing failed:", + (err as Error).message, + ); + res.status(500).json({ error: "processing_failed" }); } }, ); +// Ory Kratos Identities ignores identity changes in a login.after response, +// so this only decides. "review" and unknown statuses are allowed. app.post( "/onfido/validate", verifyWebhookSecret, - async (req: Request, res: Response) => { - const { kratos_identity_id, onfido_verification_status } = req.body; - if (!kratos_identity_id || !onfido_verification_status) { - res.json({ success: true }); - return; - } - if (onfido_verification_status === "approved") { - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_public/onfido_last_validated_at", - value: new Date().toISOString(), - }, - ], - }); - } catch (err) { - console.warn("Could not update last_validated_at:", (err as Error).message); - } - res.json({ success: true }); - return; - } - if (onfido_verification_status === "declined") { - res.status(400).json({ - messages: [ - { - instance_ptr: "#/", - message: "Identity verification failed. Please contact support.", - type: "error", - context: { reason: "identity_verification_declined" }, - }, - ], - }); + (req: Request, res: Response) => { + if (req.body.onfido_verification_status === "declined") { + block(res, "Identity verification failed. Please contact support."); return; } - // "review" or unknown — allow with a warning. - res.json({ success: true, warning: "Identity verification under review" }); + res.json({}); }, ); diff --git a/identity-verification/persona/README.md b/identity-verification/persona/README.md index 08c7362..7593741 100644 --- a/identity-verification/persona/README.md +++ b/identity-verification/persona/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Ory Engineering -[Persona](https://withpersona.com) is an identity verification platform with configurable templates (document capture, selfie + liveness, database checks, watchlist screening). This integration runs a Persona Inquiry from Ory Actions during registration, consumes Persona's HMAC-signed callback when the inquiry completes, and gates login on the stored verification status. +[Persona](https://withpersona.com) is an identity verification platform with configurable templates (document capture, selfie + liveness, database checks, watchlist screening). This integration creates a Persona Inquiry after registration, consumes Persona's HMAC-signed callback when the inquiry changes status, and gates login on the stored verification status. **Type:** webhook (Ory Actions over HTTP — code in [`webhook/`](./webhook/)) **Docs page:** [ory.com/docs/integrates-with/identity-verification/persona](https://www.ory.com/docs/integrates-with/identity-verification/persona) @@ -11,9 +11,11 @@ | Path | Trigger | Auth | Purpose | | --- | --- | --- | --- | -| `POST /persona/initiate` | sync `registration.after` | `X-Webhook-Secret` | Creates a Persona Inquiry with `reference-id = `; writes the inquiry id + hosted URL to `metadata_admin`; returns the hosted URL to the client. | -| `POST /persona/callback` | async, FROM Persona | `X-Persona-Signature` (HMAC-SHA256 of raw body) | Resolves the Kratos identity via the inquiry's `reference-id`; PATCHes the verification status (`approved` / `declined` / `marked-for-review`) onto the identity. | -| `POST /persona/validate` | sync `login.after`, `can_interrupt: true` | `X-Webhook-Secret` | Blocks login when `metadata_admin.persona.status == "declined"`. | +| `POST /persona/initiate` | your backend after sign-up, or `registration.after` without `response.parse` | `X-Webhook-Secret` | Creates a Persona Inquiry with `reference-id = `, stores the inquiry ID in `metadata_public.persona_inquiry_id`, and returns the hosted `verification_url`. | +| `POST /persona/callback` | async, FROM Persona | `Persona-Signature` header: `t=,v1=.">` | Resolves the identity via the inquiry's `reference-id` and writes the inquiry status to `metadata_public.persona_verification_status`. | +| `POST /persona/validate` | `login.after`, `response.parse: true` | `X-Webhook-Secret` | Blocks login when `metadata_public.persona_verification_status == "declined"`. | + +Persona sets the inquiry status to `completed`, `failed`, or `expired` as the user finishes. `approved`, `declined`, and `needs_review` appear only when your Persona template or workflow makes that decision. ## Required env vars @@ -24,7 +26,7 @@ ORY_API_KEY Ory admin API token (identities:write) PERSONA_API_KEY Persona API key PERSONA_TEMPLATE_ID Persona template (configures which checks run) PERSONA_WEBHOOK_SECRET HMAC secret for /persona/callback -PERSONA_INQUIRY_TYPE default: hosted-embedded +PERSONA_HOSTED_URL default: https://inquiry.withpersona.com/verify PERSONA_API_BASE default: https://api.withpersona.com/api/v1 PORT default: 3000 ``` @@ -40,10 +42,18 @@ npm start # runs `tsx server.ts` ## Configure Ory -1. Use [`ory-actions.yaml`](./ory-actions.yaml) as the snippet for `selfservice.flows.{registration,login}.after.hooks`. -2. The body templates are in [`jsonnet/`](./jsonnet/). -3. Set `ORY_WEBHOOK_SECRET` (handler) to match the `X-Webhook-Secret` value declared in the action config. -4. The `/persona/callback` URL must be publicly reachable; configure it as the webhook target in **Persona → Webhooks** with the same shared secret as `PERSONA_WEBHOOK_SECRET`. +1. Start the inquiry from your application. Ory doesn't relay webhook responses to the browser, so an Ory Action can't send the user to Persona. After sign-up, have your backend call `/persona/initiate` with the `X-Webhook-Secret` header and the identity ID and traits from the Ory session, then send the user to the returned `verification_url`. +2. To create the inquiry from an Ory Action instead, use the registration hook in [`ory-actions.yaml`](./ory-actions.yaml). It has no `response.parse`, so it runs after the identity is saved. Your backend then reads `persona_inquiry_id` from the identity through the Ory Admin API and sends the user to the inquiry. With `response.parse: true`, the action would run before the identity is saved and receive the ID `00000000-0000-0000-0000-000000000000`. +3. Merge [`ory-actions.yaml`](./ory-actions.yaml) into your identity configuration. It puts the hooks in the method-specific lists (`selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`). Both lists are aliased into the `oidc`, `code`, `passkey`, and `webauthn` lists, because Ory runs the flow-level `after.hooks` list only for a method that has no hooks of its own. Add any other method you enable, such as `saml`, the same way. Keep the `session` hook last in the registration lists. +4. The body templates are in [`jsonnet/`](./jsonnet/). On Ory Network, encode each template with `base64 < jsonnet/validate.jsonnet | tr -d '\n'` and set `body: base64://`. The webhook payload has no `metadata_admin`, so the status lives in `metadata_public`. +5. `/persona/validate` blocks sign-in with `403` and this body, which Ory shows to the user: + + ```json + {"messages": [{"instance_ptr": "#/", "messages": [{"id": 9001001, "text": "Identity verification failed. Please contact support.", "type": "error"}]}]} + ``` + +6. Set `ORY_WEBHOOK_SECRET` (handler) to match the `X-Webhook-Secret` value declared in the action config. +7. The `/persona/callback` URL must be publicly reachable; configure it as the webhook target in **Persona → Webhooks**, subscribe it to the inquiry events you use, and set `PERSONA_WEBHOOK_SECRET` to that webhook's secret. ## License diff --git a/identity-verification/persona/jsonnet/initiate.jsonnet b/identity-verification/persona/jsonnet/initiate.jsonnet index 76ded4e..721dd95 100644 --- a/identity-verification/persona/jsonnet/initiate.jsonnet +++ b/identity-verification/persona/jsonnet/initiate.jsonnet @@ -1,9 +1,11 @@ -// Body for the sync post-registration Ory Action that starts a Persona inquiry. -function(ctx) { - kratos_identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "first") - then ctx.identity.traits.name.first else "", - last_name: if std.objectHas(ctx.identity.traits, "name") && std.objectHas(ctx.identity.traits.name, "last") - then ctx.identity.traits.name.last else "", -} +// Body for the post-registration Ory Action that starts a Persona inquiry. +// The action runs after the identity is saved, so ctx.identity.id is the real ID. +function(ctx) + local traits = std.get(ctx.identity, 'traits', {}); + local name = std.get(traits, 'name', {}); + { + kratos_identity_id: ctx.identity.id, + email: std.get(traits, 'email', null), + first_name: if name == null then '' else std.get(name, 'first', ''), + last_name: if name == null then '' else std.get(name, 'last', ''), + } diff --git a/identity-verification/persona/jsonnet/validate.jsonnet b/identity-verification/persona/jsonnet/validate.jsonnet index 1b3c53f..099a195 100644 --- a/identity-verification/persona/jsonnet/validate.jsonnet +++ b/identity-verification/persona/jsonnet/validate.jsonnet @@ -1,11 +1,9 @@ -// Body for the sync post-login Ory Action that reads stored Persona -// verification status and blocks login when it's "declined". -function(ctx) { - kratos_identity_id: ctx.identity.id, - persona_inquiry_id: - if ctx.identity.metadata_admin != null && std.objectHas(ctx.identity.metadata_admin, "persona_inquiry_id") - then ctx.identity.metadata_admin.persona_inquiry_id else null, - persona_verification_status: - if ctx.identity.metadata_admin != null && std.objectHas(ctx.identity.metadata_admin, "persona_verification_status") - then ctx.identity.metadata_admin.persona_verification_status else null, -} +// Body for the post-login Ory Action that reads the stored Persona +// verification status and blocks login when it's "declined". The webhook +// payload has no metadata_admin, so the status lives in metadata_public. +function(ctx) + local metadata = std.get(ctx.identity, 'metadata_public', null); + { + persona_verification_status: + if metadata == null then null else std.get(metadata, 'persona_verification_status', null), + } diff --git a/identity-verification/persona/ory-actions.yaml b/identity-verification/persona/ory-actions.yaml index 9f2c1fc..0033cf6 100644 --- a/identity-verification/persona/ory-actions.yaml +++ b/identity-verification/persona/ory-actions.yaml @@ -1,44 +1,77 @@ # Persona identity-verification hooks. # -# Two Ory-side hooks (initiate + validate). Persona also POSTs back to -# /persona/callback when an inquiry completes; that endpoint is gated by -# Persona's HMAC-SHA256 signature, not by this Ory webhook secret. +# Ory doesn't relay webhook responses to the browser, so the recommended path +# is for your backend to call /persona/initiate after sign-up and send the +# user to the returned verification_url. The registration hook below is the +# alternative: it creates the inquiry from an Ory Action, and your backend +# reads persona_inquiry_id from metadata_public afterward. +# +# The registration hook has no response.parse, so it runs after the identity +# is saved and receives the real identity ID. response.ignore makes it run in +# the background. +# +# Ory Kratos Identities runs the flow-level after.hooks list only for a method +# that has no hooks of its own, so the hooks go in the method lists. The +# password lists are aliased into the oidc, code, passkey, and webauthn lists, +# so every sign-in method runs the same hooks. Alias them into any other +# method you enable, such as saml, the same way. +# +# On Ory Network, replace each file:// body with +# base64://. +# +# Persona also POSTs back to /persona/callback; that endpoint is gated by the +# Persona-Signature header, not by this Ory webhook secret. selfservice: flows: registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/persona/initiate - method: POST - body: file://./jsonnet/initiate.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/persona/initiate + method: POST + body: file://./jsonnet/initiate.jsonnet + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/persona/validate - method: POST - body: file://./jsonnet/validate.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/persona/validate + method: POST + body: file://./jsonnet/validate.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/identity-verification/persona/registry.entry.yaml b/identity-verification/persona/registry.entry.yaml index 6d9fa9c..78f0cbf 100644 --- a/identity-verification/persona/registry.entry.yaml +++ b/identity-verification/persona/registry.entry.yaml @@ -9,17 +9,17 @@ type: webhook maintainedBy: Ory Engineering description: >- Run Persona identity verification (configurable templates — document capture, selfie + liveness, database - checks, watchlist screening) from Ory Actions during registration; consume Persona's HMAC-signed callback - when the inquiry completes and gate login on the stored verification status. + checks, watchlist screening) after registration; consume Persona's HMAC-signed callback + when the inquiry changes status and gate login on the stored verification status. useCase: >- A regulated B2C product needs identity verification at signup tailored to product risk — Persona's templates let the same handler back KYC-light onboarding (document + selfie) and high-trust flows (with database verification + watchlist) without changing code. coreFunctionality: |- - - Three endpoints: POST /persona/initiate (sync registration.after; creates Inquiry with reference-id = Kratos identity id; returns hosted URL); POST /persona/callback (async from Persona; HMAC-SHA256 via X-Persona-Signature; PATCHes verdict to identity metadata); POST /persona/validate (sync login.after with can_interrupt: true; blocks login when status == declined). - - Identity-side state stored in metadata_admin via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Three endpoints: POST /persona/initiate (called by the application backend after sign-up, or registration.after without response.parse; creates Inquiry with reference-id = Ory identity id; returns hosted URL); POST /persona/callback (async from Persona; Persona-Signature t=,v1= HMAC-SHA256 over "."; writes the status to metadata_public); POST /persona/validate (login.after with response.parse; blocks login when status == declined). + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity), because the webhook payload has no metadata_admin. - Reference-id is the binding between Persona Inquiry and Kratos identity — set on initiate, consumed on callback. - - Configured via Ory Actions on registration.after (sync) + login.after (sync, can_interrupt) — no Kratos schema changes required. + - Hooks go in the method-specific lists (after.password.hooks, after.oidc.hooks, ...) — no Kratos schema changes required. - SDK: @ory/client for Kratos admin API; native fetch for Persona API. oryMechanism: - actions diff --git a/identity-verification/persona/webhook/.env.example b/identity-verification/persona/webhook/.env.example index e7bc467..28d663f 100644 --- a/identity-verification/persona/webhook/.env.example +++ b/identity-verification/persona/webhook/.env.example @@ -5,11 +5,11 @@ ORY_WEBHOOK_SECRET= KRATOS_ADMIN_URL= ORY_API_KEY= -# Persona API base + API key + inquiry template + inquiry type. +# Persona API base + API key + inquiry template + hosted flow base URL. PERSONA_API_BASE=https://api.withpersona.com/api/v1 PERSONA_API_KEY= PERSONA_TEMPLATE_ID= -PERSONA_INQUIRY_TYPE=hosted-embedded +PERSONA_HOSTED_URL=https://inquiry.withpersona.com/verify # Persona webhook signing secret (Persona Dashboard -> Settings -> Webhooks). # Leave blank to disable /persona/callback (returns 503). diff --git a/identity-verification/persona/webhook/server.ts b/identity-verification/persona/webhook/server.ts index 80f097a..bf9a841 100644 --- a/identity-verification/persona/webhook/server.ts +++ b/identity-verification/persona/webhook/server.ts @@ -3,16 +3,17 @@ // Persona <> Ory Actions webhook handler. // // Three endpoints: -// POST /persona/initiate — sync post-registration. Creates a Persona -// Inquiry with reference-id = Kratos identity -// id, returns the hosted verification URL -// and inquiry id, writes IDs to metadata_admin. -// POST /persona/callback — async webhook FROM Persona. HMAC-SHA256 of -// the raw body via X-Persona-Signature. -// Resolves the Kratos identity via the -// inquiry's reference-id. -// POST /persona/validate — sync post-login. Blocks login when stored -// status is "declined". +// POST /persona/initiate — called by your backend after sign-up, or by a +// registration.after Ory Action without +// response.parse. Creates a Persona Inquiry with +// reference-id = Ory identity ID, stores the +// inquiry ID on the identity, and returns the +// hosted verification URL. +// POST /persona/callback — async webhook FROM Persona, verified with the +// Persona-Signature header. Resolves the identity +// via the inquiry's reference-id. +// POST /persona/validate — sync post-login Ory Action. Blocks login when +// the stored status is "declined". import "dotenv/config"; import crypto from "node:crypto"; @@ -21,7 +22,12 @@ import express, { type Request, type Response, } from "express"; -import { Configuration, IdentityApi, JsonPatchOpEnum } from "@ory/client"; +import { + Configuration, + IdentityApi, + type JsonPatch, + JsonPatchOpEnum, +} from "@ory/client"; const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; @@ -29,9 +35,11 @@ const KRATOS_ADMIN_URL = process.env.KRATOS_ADMIN_URL; const ORY_API_KEY = process.env.ORY_API_KEY; const PERSONA_API_KEY = process.env.PERSONA_API_KEY; const PERSONA_TEMPLATE_ID = process.env.PERSONA_TEMPLATE_ID; -const PERSONA_INQUIRY_TYPE = process.env.PERSONA_INQUIRY_TYPE ?? "hosted-embedded"; +const PERSONA_HOSTED_URL = + process.env.PERSONA_HOSTED_URL ?? "https://inquiry.withpersona.com/verify"; const PERSONA_WEBHOOK_SECRET = process.env.PERSONA_WEBHOOK_SECRET; -const PERSONA_API_BASE = process.env.PERSONA_API_BASE ?? "https://api.withpersona.com/api/v1"; +const PERSONA_API_BASE = + process.env.PERSONA_API_BASE ?? "https://api.withpersona.com/api/v1"; if ( !ORY_WEBHOOK_SECRET || @@ -50,6 +58,108 @@ const kratos = new IdentityApi( new Configuration({ basePath: KRATOS_ADMIN_URL, accessToken: ORY_API_KEY }), ); +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +// Patch only this integration's keys. When a metadata parent is null, the +// updated_at test prevents replacing data written since the GET; a stale +// snapshot is fetched again before retrying. +async function mergeMetadata( + id: string, + update: { + metadata_public?: Record; + metadata_admin?: Record; + }, +): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const { data: identity } = await kratos.getIdentity({ id }); + const jsonPatch: JsonPatch[] = []; + for (const field of ["metadata_public", "metadata_admin"] as const) { + const values = update[field]; + if (!values) continue; + const entries = Object.entries(values).filter( + ([, value]) => value !== undefined, + ); + if (entries.length === 0) continue; + if (identity[field] == null) { + if (!identity.updated_at) + throw new Error("Identity is missing updated_at"); + jsonPatch.push({ + op: JsonPatchOpEnum.Test, + path: "/updated_at", + value: identity.updated_at, + }); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}`, + value: Object.fromEntries(entries), + }); + } else { + for (const [key, value] of entries) { + const escaped = key.replace(/~/g, "~0").replace(/\//g, "~1"); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}/${escaped}`, + value, + }); + } + } + } + if (jsonPatch.length === 0) return; + try { + await kratos.patchIdentity({ id, jsonPatch }); + return; + } catch (error) { + const response = ( + error as { response?: { status?: number; data?: { reason?: string } } } + ).response; + if ( + attempt === 2 || + response?.status !== 400 || + response.data?.reason !== "failed to apply JSON patch" + ) + throw error; + } + } +} + +// Ory Kratos Identities shows `text` to the user only for this response shape. +function block(res: Response, text: string): void { + res.status(403).json({ + messages: [ + { instance_ptr: "#/", messages: [{ id: 9001001, text, type: "error" }] }, + ], + }); +} + +// Persona-Signature is `t=,v1=`, with several space-separated +// pairs while a secret rotates. Each v1 is an HMAC-SHA256 of ".". +// Signatures older than five minutes are rejected to limit replays. +function verifyPersonaSignature( + header: string, + rawBody: Buffer, + secret: string, +): boolean { + for (const pair of header.split(" ")) { + const parts = Object.fromEntries( + pair.split(",").map((kv) => { + const i = kv.indexOf("="); + return [kv.slice(0, i), kv.slice(i + 1)]; + }), + ); + if (!parts.t || !parts.v1) continue; + if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) continue; + const expected = crypto + .createHmac("sha256", secret) + .update(`${parts.t}.`) + .update(rawBody) + .digest("hex"); + const a = Buffer.from(parts.v1); + const b = Buffer.from(expected); + if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true; + } + return false; +} + interface InitiateRequest { kratos_identity_id?: string; email?: string; @@ -58,31 +168,34 @@ interface InitiateRequest { } interface ValidateRequest { - kratos_identity_id?: string; - persona_inquiry_id?: string; persona_verification_status?: string; } interface PersonaInquiry { id: string; + type?: string; attributes?: { status?: string; "reference-id"?: string; - "inquiry-links"?: { "hosted-url"?: string }; - }; - relationships?: { - verifications?: { - data?: Array<{ - type?: string; - attributes?: { status?: string; result?: unknown }; - }>; - }; }; } -interface PersonaCallbackBody { - "event-type"?: string; - data?: PersonaInquiry; +interface PersonaIncluded { + id: string; + type?: string; + attributes?: { status?: string }; +} + +// Persona wraps every webhook in an event resource. The inquiry snapshot sits +// in data.attributes.payload.data. +interface PersonaEventBody { + data?: { + type?: string; + attributes?: { + name?: string; + payload?: { data?: PersonaInquiry }; + }; + }; } declare module "express-serve-static-core" { @@ -113,10 +226,17 @@ function verifyWebhookSecret(req: Request, res: Response, next: NextFunction) { const PERSONA_AUTH = `Bearer ${PERSONA_API_KEY}`; -async function personaFetch(method: "GET" | "POST", path: string, body?: unknown): Promise { +async function personaFetch( + method: "GET" | "POST", + path: string, + body?: unknown, +): Promise { const init: RequestInit = { method, - headers: { authorization: PERSONA_AUTH, "content-type": "application/json" }, + headers: { + authorization: PERSONA_AUTH, + "content-type": "application/json", + }, signal: AbortSignal.timeout(15000), }; if (body !== undefined) init.body = JSON.stringify(body); @@ -136,67 +256,65 @@ app.post( "/persona/initiate", verifyWebhookSecret, async (req: Request, res: Response) => { - const { kratos_identity_id, email } = req.body; - if (!kratos_identity_id || !email) { - res.status(400).json({ error: "kratos_identity_id and email are required" }); + const { kratos_identity_id, email, first_name, last_name } = req.body; + if (!kratos_identity_id || kratos_identity_id === NIL_UUID || !email) { + res + .status(400) + .json({ error: "a saved kratos_identity_id and email are required" }); return; } try { - const inquiryRes = await personaFetch<{ data: PersonaInquiry }>("POST", "/inquiries", { - data: { - type: "inquiry", - attributes: { - "inquiry-template-id": PERSONA_TEMPLATE_ID, - "inquiry-type": PERSONA_INQUIRY_TYPE, - email, - "reference-id": kratos_identity_id, + const inquiryRes = await personaFetch<{ data: PersonaInquiry }>( + "POST", + "/inquiries", + { + data: { + type: "inquiry", + attributes: { + "inquiry-template-id": PERSONA_TEMPLATE_ID, + fields: { + "email-address": email, + ...(first_name && { "name-first": first_name }), + ...(last_name && { "name-last": last_name }), + }, + "reference-id": kratos_identity_id, + }, }, }, - }); + ); const inquiry = inquiryRes.data; - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_verification_status", value: inquiry.attributes?.status ?? "initiated" }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_inquiry_id", value: inquiry.id }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_initiated_at", value: new Date().toISOString() }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_inquiry_template", value: PERSONA_TEMPLATE_ID }, - ], + await mergeMetadata(kratos_identity_id, { + metadata_public: { + persona_verification_status: inquiry.attributes?.status ?? "created", + persona_inquiry_id: inquiry.id, + persona_initiated_at: new Date().toISOString(), + }, + metadata_admin: { persona_inquiry_template: PERSONA_TEMPLATE_ID }, }); + const verificationUrl = new URL(PERSONA_HOSTED_URL); + verificationUrl.searchParams.set("inquiry-id", inquiry.id); res.json({ - success: true, inquiry_id: inquiry.id, status: inquiry.attributes?.status, - verification_url: inquiry.attributes?.["inquiry-links"]?.["hosted-url"] ?? "", + verification_url: verificationUrl.toString(), }); } catch (err) { console.error("Persona initiate error:", (err as Error).message); - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/persona_initiation_error", - value: (err as Error).message, - }, - ], - }); - } catch (e) { - console.error("Failed to record initiation error:", (e as Error).message); - } - res.status(200).json({ success: false, error: (err as Error).message }); + res.status(502).json({ error: "persona_initiate_failed" }); } }, ); -// Async callback FROM Persona. HMAC-SHA256 of the RAW body via X-Persona-Signature. +// Async callback FROM Persona. Subscribe it to the inquiry events your +// template emits, such as inquiry.completed, inquiry.approved, and +// inquiry.declined. The handler answers 2xx only after the identity is +// updated, so Persona retries a delivery that failed to apply. app.post( "/persona/callback", - async (req: Request, res: Response) => { + async (req: Request, res: Response) => { if (!PERSONA_WEBHOOK_SECRET) { res.status(503).json({ error: "callback_disabled" }); return; @@ -206,99 +324,81 @@ app.post( res.status(400).json({ error: "missing body" }); return; } - const sig = req.header("x-persona-signature") ?? ""; - const expected = crypto - .createHmac("sha256", PERSONA_WEBHOOK_SECRET) - .update(rawBody) - .digest("hex"); - const a = Buffer.from(sig); - const b = Buffer.from(expected); - if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + if ( + !verifyPersonaSignature( + req.header("persona-signature") ?? "", + rawBody, + PERSONA_WEBHOOK_SECRET, + ) + ) { res.status(401).json({ error: "invalid signature" }); return; } - res.json({ received: true }); - - if (req.body["event-type"] !== "inquiry.completed") return; + const eventName = req.body.data?.attributes?.name ?? ""; + const snapshot = req.body.data?.attributes?.payload?.data; + if (!eventName.startsWith("inquiry.") || snapshot?.type !== "inquiry") { + res.json({ received: true, ignored: true }); + return; + } - const inquiryId = req.body.data?.id; - const referenceId = req.body.data?.attributes?.["reference-id"]; - if (!inquiryId || !referenceId) { - console.error("Persona callback missing inquiry id or reference-id"); + const inquiryId = snapshot.id; + const referenceId = snapshot.attributes?.["reference-id"]; + if (!inquiryId || !referenceId || referenceId === NIL_UUID) { + console.error(`Persona ${eventName}: inquiry has no usable reference-id`); + res.json({ received: true, ignored: true }); return; } try { - const detailRes = await personaFetch<{ data: PersonaInquiry }>("GET", `/inquiries/${inquiryId}`); - const status = detailRes.data.attributes?.status ?? "unknown"; + // Event deliveries can arrive out of order, so the current inquiry state + // is fetched instead of trusting the snapshot in the event. + const detail = await personaFetch<{ + data: PersonaInquiry; + included?: PersonaIncluded[]; + }>("GET", `/inquiries/${encodeURIComponent(inquiryId)}`); + const status = detail.data.attributes?.status ?? "unknown"; - const verifications = detailRes.data.relationships?.verifications?.data ?? []; - const verificationResults: Record = {}; - for (const v of verifications) { - if (v.type) { - verificationResults[v.type] = { status: v.attributes?.status, result: v.attributes?.result }; + const verificationResults: Record = {}; + for (const item of detail.included ?? []) { + if (item.type?.startsWith("verification/")) { + verificationResults[`${item.type}:${item.id}`] = + item.attributes?.status; } } - await kratos.patchIdentity({ - id: referenceId, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_verification_status", value: status }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_inquiry_status", value: status }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_completed_at", value: new Date().toISOString() }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/persona_verifications", value: JSON.stringify(verificationResults) }, - ], + await mergeMetadata(referenceId, { + metadata_public: { + persona_verification_status: status, + persona_updated_at: new Date().toISOString(), + }, + metadata_admin: { persona_verifications: verificationResults }, }); - console.log(`Persona callback: identity=${referenceId} status=${status}`); + console.log( + `Persona ${eventName}: identity=${referenceId} status=${status}`, + ); + res.json({ received: true }); } catch (err) { - console.error("Persona callback processing failed:", (err as Error).message); + console.error( + "Persona callback processing failed:", + (err as Error).message, + ); + res.status(500).json({ error: "processing_failed" }); } }, ); +// Ory Kratos Identities ignores identity changes in a login.after response, +// so this only decides. Other statuses, such as needs_review, are allowed. app.post( "/persona/validate", verifyWebhookSecret, - async (req: Request, res: Response) => { - const { kratos_identity_id, persona_inquiry_id, persona_verification_status } = req.body; - if (!kratos_identity_id || !persona_inquiry_id || !persona_verification_status) { - res.json({ success: true }); - return; - } - if (persona_verification_status === "approved") { - try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { - op: JsonPatchOpEnum.Replace, - path: "/metadata_admin/persona_last_validated_at", - value: new Date().toISOString(), - }, - ], - }); - } catch (err) { - console.warn("Could not update last_validated_at:", (err as Error).message); - } - res.json({ success: true }); - return; - } - if (persona_verification_status === "declined") { - res.status(400).json({ - messages: [ - { - instance_ptr: "#/", - message: "Identity verification failed. Please contact support.", - type: "error", - context: { reason: "identity_verification_declined" }, - }, - ], - }); + (req: Request, res: Response) => { + if (req.body.persona_verification_status === "declined") { + block(res, "Identity verification failed. Please contact support."); return; } - // "needs_review" / unknown — allow with a warning. - res.json({ success: true, warning: "Identity verification under review" }); + res.json({}); }, ); diff --git a/identity-verification/prove/README.md b/identity-verification/prove/README.md index 34e2173..d00eeae 100644 --- a/identity-verification/prove/README.md +++ b/identity-verification/prove/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Ory Engineering -[Prove](https://www.prove.com) (formerly Payfone) is a phone-anchored identity platform — telecom signals, SIM-swap detection, identity prefill, and continuous phone-ownership verification. This integration calls Prove from Ory Actions across three lifecycle points: pre-registration lookup (prefill), post-registration enrollment, and per-login validation. +[Prove](https://www.prove.com) (formerly Payfone) is a phone-anchored identity platform — telecom signals, SIM-swap detection, identity prefill, and continuous phone-ownership verification. This integration calls Prove from Ory Actions at two lifecycle points: enrollment after registration, and validation at each sign-in. **Type:** webhook (Ory Actions over HTTP — code in [`webhook/`](./webhook/)) **Docs page:** [ory.com/docs/integrates-with/identity-verification/prove](https://www.ory.com/docs/integrates-with/identity-verification/prove) @@ -11,11 +11,12 @@ | Path | Trigger | Auth | Purpose | | --- | --- | --- | --- | -| `POST /prove/lookup` | sync `registration.before`, `can_interrupt: true` | `X-Webhook-Secret` | Pre-registration phone-number lookup. Returns identity-prefill metadata (name, address, DOB) when Prove knows the phone, or surfaces a SIM-swap / low-trust signal that the registration hook can use to gate the flow. | -| `POST /prove/enroll` | sync `registration.after` | `X-Webhook-Secret` | Registers the user's phone with Prove's Identity Manager; stores the resulting `prove_identity_id` on `metadata_admin`. | -| `POST /prove/validate` | sync `login.after`, `can_interrupt: true` | `X-Webhook-Secret` | Confirms the user's stored Prove identity is still in good standing (no recent SIM swap, ownership unchanged); blocks login on a fail. | +| `POST /prove/enroll` | `registration.after` without `response.parse`, after the identity is saved | `X-Webhook-Secret` | Registers the user's phone with Prove's Identity Manager; stores the resulting `prove_identity_id` in `metadata_public`. | +| `POST /prove/validate` | `login.after`, `response.parse: true` | `X-Webhook-Secret` | Confirms the phone number Prove has on file still matches the user's; blocks sign-in on a mismatch. | -All three are sync — Prove's APIs return decisions in the response, so there is no async callback path to verify. +Prove's APIs return decisions in the response, so there is no async callback path to verify. + +Prove's prefill isn't part of this integration. A `registration.before` action runs when Ory creates the flow, before the user enters anything, so its payload has no phone number, traits, or identity. To prefill a registration form, call Prove from your application before you show the form. ## Required env vars @@ -41,9 +42,18 @@ npm start # runs `tsx server.ts` ## Configure Ory -1. Use [`ory-actions.yaml`](./ory-actions.yaml) as the snippet for `registration.before` (lookup), `registration.after` (enroll), and `login.after` (validate). -2. The body templates are in [`jsonnet/`](./jsonnet/). -3. **Phone collection is a prerequisite** — your identity schema must capture a phone number in E.164 format before the `prove/lookup` hook fires; surface the field on your registration UI. +1. Merge [`ory-actions.yaml`](./ory-actions.yaml) into your identity configuration. It puts the hooks in the method-specific lists (`selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`). Both lists are aliased into the `oidc`, `code`, `passkey`, and `webauthn` lists, because Ory runs the flow-level `after.hooks` list only for a method that has no hooks of its own. Add any other method you enable, such as `saml`, the same way. Keep the `session` hook last in the registration lists. +2. The body templates are in [`jsonnet/`](./jsonnet/). On Ory Network, encode each template with `base64 < jsonnet/enroll.jsonnet | tr -d '\n'` and set `body: base64://`. +3. Keep `response.parse` off for `/prove/enroll`. With `response.parse: true`, the action runs before the identity is saved and receives the ID `00000000-0000-0000-0000-000000000000`, so the handler's Admin API update can't find the identity. +4. Keep `prove_identity_id` in `metadata_public`. The webhook payload doesn't include `metadata_admin`, so the validate action can't read values stored there. +5. `/prove/validate` blocks sign-in with `403` and this body, which Ory shows to the user. A `200` response always lets the user in, and Ory doesn't save identity changes that a `login.after` action returns: + + ```json + {"messages": [{"instance_ptr": "#/", "messages": [{"id": 9001001, "text": "We couldn't confirm your phone number. Please contact support.", "type": "error"}]}]} + ``` + + Users without a Prove enrollment, and sign-ins while Prove can't be reached, are let through. Change that in the handler if you want to fail closed. +6. **Phone collection is a prerequisite** — your identity schema must capture a phone number in E.164 format; surface the field on your registration UI. ## Notes diff --git a/identity-verification/prove/jsonnet/enroll.jsonnet b/identity-verification/prove/jsonnet/enroll.jsonnet index 3da05bc..75b0951 100644 --- a/identity-verification/prove/jsonnet/enroll.jsonnet +++ b/identity-verification/prove/jsonnet/enroll.jsonnet @@ -1,6 +1,7 @@ // Body for the post-registration Ory Action that enrolls the user's phone -// in Prove's Identity Manager. +// in Prove's Identity Manager. The action runs after the identity is saved, +// so ctx.identity.id is the real ID. function(ctx) { kratos_identity_id: ctx.identity.id, - phone_number: if std.objectHas(ctx.identity.traits, "phone_number") then ctx.identity.traits.phone_number else "", + phone_number: std.get(std.get(ctx.identity, 'traits', {}), 'phone_number', ''), } diff --git a/identity-verification/prove/jsonnet/lookup.jsonnet b/identity-verification/prove/jsonnet/lookup.jsonnet deleted file mode 100644 index 1625639..0000000 --- a/identity-verification/prove/jsonnet/lookup.jsonnet +++ /dev/null @@ -1,13 +0,0 @@ -// Body for the pre-registration Ory Action that pre-fills identity traits -// from Prove's phone-anchored identity graph. Run as a `before:` hook on the -// registration flow with response.parse: true so Ory absorbs the prefill. -function(ctx) { - phone_number: - if std.objectHas(ctx, "flow") && std.objectHas(ctx.flow, "transient_payload") - && ctx.flow.transient_payload != null - && std.objectHas(ctx.flow.transient_payload, "phone_number") - then ctx.flow.transient_payload.phone_number - else if std.objectHas(ctx.identity.traits, "phone_number") - then ctx.identity.traits.phone_number - else "", -} diff --git a/identity-verification/prove/jsonnet/validate.jsonnet b/identity-verification/prove/jsonnet/validate.jsonnet index cc9fc55..fbe9525 100644 --- a/identity-verification/prove/jsonnet/validate.jsonnet +++ b/identity-verification/prove/jsonnet/validate.jsonnet @@ -1,10 +1,9 @@ -// Body for the sync post-login Ory Action that validates the stored phone -// against what Prove has on file. -function(ctx) { - kratos_identity_id: ctx.identity.id, - phone_number: if std.objectHas(ctx.identity.traits, "phone_number") then ctx.identity.traits.phone_number else "", - prove_identity_id: - if ctx.identity.metadata_public != null && std.objectHas(ctx.identity.metadata_public, "prove_identity_id") - then ctx.identity.metadata_public.prove_identity_id - else null, -} +// Body for the post-login Ory Action that validates the stored phone +// against what Prove has on file. The webhook payload has no metadata_admin, +// so prove_identity_id lives in metadata_public. +function(ctx) + local metadata = std.get(ctx.identity, 'metadata_public', null); + { + phone_number: std.get(std.get(ctx.identity, 'traits', {}), 'phone_number', ''), + prove_identity_id: if metadata == null then null else std.get(metadata, 'prove_identity_id', null), + } diff --git a/identity-verification/prove/ory-actions.yaml b/identity-verification/prove/ory-actions.yaml index 6979fea..47c0fbc 100644 --- a/identity-verification/prove/ory-actions.yaml +++ b/identity-verification/prove/ory-actions.yaml @@ -1,60 +1,71 @@ # Prove phone-anchored identity hooks. # -# Three hooks: pre-registration prefill, post-registration enrollment, -# post-login validation. All authenticated by the shared ORY_WEBHOOK_SECRET. +# Two hooks: post-registration enrollment and post-login validation, both +# authenticated by the shared ORY_WEBHOOK_SECRET. +# +# The enroll hook has no response.parse, so it runs after the identity is +# saved and receives the real identity ID. response.ignore makes it run in the +# background so the Prove call doesn't delay sign-up. +# +# Ory Kratos Identities runs the flow-level after.hooks list only for a method +# that has no hooks of its own, so the hooks go in the method lists. The +# password lists are aliased into the oidc, code, passkey, and webauthn lists, +# so every sign-in method runs the same hooks. Alias them into any other +# method you enable, such as saml, the same way. +# +# On Ory Network, replace each file:// body with +# base64://. selfservice: flows: registration: - before: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/prove/lookup - method: POST - body: file://./jsonnet/lookup.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/prove/enroll - method: POST - body: file://./jsonnet/enroll.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/prove/enroll + method: POST + body: file://./jsonnet/enroll.jsonnet + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/prove/validate - method: POST - body: file://./jsonnet/validate.jsonnet - can_interrupt: true - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/prove/validate + method: POST + body: file://./jsonnet/validate.jsonnet + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/identity-verification/prove/registry.entry.yaml b/identity-verification/prove/registry.entry.yaml index 829c415..2cd7fdb 100644 --- a/identity-verification/prove/registry.entry.yaml +++ b/identity-verification/prove/registry.entry.yaml @@ -8,18 +8,16 @@ category: identity-verification type: webhook maintainedBy: Ory Engineering description: >- - Phone-anchored identity verification — telecom signals, SIM-swap detection, identity prefill, and continuous - phone-ownership verification — across three Ory Actions lifecycle points: pre-registration lookup (prefill), - post-registration enrollment, and per-login validation. + Phone-anchored identity verification — telecom signals and continuous phone-ownership verification — at two + Ory Actions lifecycle points: post-registration enrollment and per-login validation. useCase: >- - A US-focused product wants signup friction reduced (Prove prefill cuts forms by autofilling name/address from - the phone number) AND wants ongoing phone-ownership verification so a SIM-swapped phone can't be used to - resume an existing account. + A US-focused product wants ongoing phone-ownership verification so a changed or SIM-swapped phone can't be + used to resume an existing account. coreFunctionality: |- - - Three sync endpoints: POST /prove/lookup (registration.before with can_interrupt — prefill or block on SIM swap), POST /prove/enroll (registration.after — enroll phone with Prove Identity Manager, store prove_identity_id), POST /prove/validate (login.after with can_interrupt — confirm continuous phone ownership, block on a fail). - - All sync — Prove's APIs return decisions in the response, no async callbacks. - - Phone collection is a prerequisite — identity schema must capture E.164 phone number before /prove/lookup fires. - - Identity-side state stored in metadata_admin via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Two endpoints: POST /prove/enroll (registration.after without response.parse, after the identity is saved — enroll phone with Prove Identity Manager, store prove_identity_id in metadata_public), POST /prove/validate (login.after with response.parse — confirm the phone on file still matches, block on a mismatch). + - Prove's APIs return decisions in the response, no async callbacks. Prefill isn't covered: a registration.before action has no phone number or traits. + - Phone collection is a prerequisite — identity schema must capture an E.164 phone number. + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity), because the webhook payload has no metadata_admin. - Uses Prove's official SDK (@prove-identity/prove-api) for the API surface. - Defaults to UAT (platform.uat.proveapis.com); override PROVE_SERVER_URL for production. oryMechanism: diff --git a/identity-verification/prove/webhook/server.ts b/identity-verification/prove/webhook/server.ts index 4f51319..0498327 100644 --- a/identity-verification/prove/webhook/server.ts +++ b/identity-verification/prove/webhook/server.ts @@ -2,16 +2,14 @@ // // Prove (phone-anchored identity) <> Ory Actions webhook handler. // -// Three endpoints — all sync Ory Action calls authenticated by the shared +// Two endpoints, both Ory Action calls authenticated by the shared // ORY_WEBHOOK_SECRET. The handler uses Prove's official SDK // (@prove-identity/prove-api) for the API surface. // -// POST /prove/lookup pre-registration phone-number lookup. Returns -// identity-prefill metadata if Prove knows the -// phone number. -// POST /prove/enroll post-registration enrollment. Registers the -// user's phone number with Prove's Identity Manager -// and stores the resulting prove_identity_id. +// POST /prove/enroll post-registration enrollment, after the identity is +// saved. Registers the user's phone number with +// Prove's Identity Manager and stores the resulting +// prove_identity_id in metadata_public. // POST /prove/validate post-login validation. Confirms the user's stored // phone matches what Prove has on file; blocks login // on mismatch. @@ -24,7 +22,12 @@ import express, { type Response, } from "express"; import { Proveapi } from "@prove-identity/prove-api"; -import { Configuration, IdentityApi, JsonPatchOpEnum } from "@ory/client"; +import { + Configuration, + IdentityApi, + type JsonPatch, + JsonPatchOpEnum, +} from "@ory/client"; const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; @@ -32,7 +35,8 @@ const KRATOS_ADMIN_URL = process.env.KRATOS_ADMIN_URL; const ORY_API_KEY = process.env.ORY_API_KEY; const PROVE_CLIENT_ID = process.env.PROVE_CLIENT_ID; const PROVE_CLIENT_SECRET = process.env.PROVE_CLIENT_SECRET; -const PROVE_SERVER_URL = process.env.PROVE_SERVER_URL ?? "https://platform.uat.proveapis.com"; +const PROVE_SERVER_URL = + process.env.PROVE_SERVER_URL ?? "https://platform.uat.proveapis.com"; if ( !ORY_WEBHOOK_SECRET || @@ -56,33 +60,89 @@ const kratos = new IdentityApi( new Configuration({ basePath: KRATOS_ADMIN_URL, accessToken: ORY_API_KEY }), ); -interface LookupRequest { - phone_number?: string; -} - interface EnrollRequest { kratos_identity_id?: string; phone_number?: string; } +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +// Patch only this integration's keys. When a metadata parent is null, the +// updated_at test prevents replacing data written since the GET; a stale +// snapshot is fetched again before retrying. +async function mergeMetadata( + id: string, + update: { + metadata_public?: Record; + metadata_admin?: Record; + }, +): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + const { data: identity } = await kratos.getIdentity({ id }); + const jsonPatch: JsonPatch[] = []; + for (const field of ["metadata_public", "metadata_admin"] as const) { + const values = update[field]; + if (!values) continue; + const entries = Object.entries(values).filter( + ([, value]) => value !== undefined, + ); + if (entries.length === 0) continue; + if (identity[field] == null) { + if (!identity.updated_at) + throw new Error("Identity is missing updated_at"); + jsonPatch.push({ + op: JsonPatchOpEnum.Test, + path: "/updated_at", + value: identity.updated_at, + }); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}`, + value: Object.fromEntries(entries), + }); + } else { + for (const [key, value] of entries) { + const escaped = key.replace(/~/g, "~0").replace(/\//g, "~1"); + jsonPatch.push({ + op: JsonPatchOpEnum.Add, + path: `/${field}/${escaped}`, + value, + }); + } + } + } + if (jsonPatch.length === 0) return; + try { + await kratos.patchIdentity({ id, jsonPatch }); + return; + } catch (error) { + const response = ( + error as { response?: { status?: number; data?: { reason?: string } } } + ).response; + if ( + attempt === 2 || + response?.status !== 400 || + response.data?.reason !== "failed to apply JSON patch" + ) + throw error; + } + } +} + +// Ory Kratos Identities shows `text` to the user only for this response shape. +function block(res: Response, text: string): void { + res.status(403).json({ + messages: [ + { instance_ptr: "#/", messages: [{ id: 9001001, text, type: "error" }] }, + ], + }); +} + interface ValidateRequest { - kratos_identity_id?: string; phone_number?: string; prove_identity_id?: string; } -// Prove's SDK uses generated types that vary by version; unwrap the response -// envelope defensively. Each response has either {Response: {...}} -// or a direct payload depending on the SDK version. -function unwrap(resp: unknown, key: string): T | undefined { - if (resp && typeof resp === "object") { - const direct = (resp as Record)[key]; - if (direct && typeof direct === "object") return direct as T; - return resp as T; - } - return undefined; -} - const app = express(); app.use(express.json()); @@ -101,105 +161,24 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); -// Pre-fill identity traits from Prove's phone-anchored identity graph. -app.post( - "/prove/lookup", - verifyWebhookSecret, - async (req: Request, res: Response) => { - const { phone_number } = req.body; - if (!phone_number) { - res.status(400).json({ error: "phone_number is required" }); - return; - } - - try { - const lookup = await prove.identity.v3GetIdentitiesByPhoneNumber(phone_number); - const list = unwrap<{ items?: unknown[] }>(lookup, "v3GetIdentitiesByPhoneNumberResponse"); - const items = Array.isArray(list?.items) ? list.items : Array.isArray(lookup) ? lookup : []; - - if (items.length === 0) { - res.json({ - identity: { - metadata_public: { - prove_identity_status: "not_found", - prove_lookup_timestamp: new Date().toISOString(), - }, - }, - }); - return; - } - - const first = items[0] as Record; - const provedIdentityId = - (first.identityId as string | undefined) ?? (first.identity_id as string | undefined); - if (!provedIdentityId) { - res.json({ - identity: { - metadata_public: { prove_identity_status: "lookup_error" }, - metadata_admin: { prove_error_message: "missing identityId in lookup item" }, - }, - }); - return; - } - - const detailResp = await prove.identity.v3GetIdentity(provedIdentityId); - const details = - unwrap>(detailResp, "v3GetIdentityResponse") ?? - (detailResp as Record); - - if (!details || details.success !== true) { - res.json({ - identity: { - metadata_public: { - prove_identity_status: "lookup_error", - }, - metadata_admin: { - prove_error_message: "Could not retrieve identity details", - }, - }, - }); - return; - } - - res.json({ - identity: { - traits: { phone_number: (details.phoneNumber as string | undefined) ?? phone_number }, - metadata_public: { - prove_identity_status: "verified", - prove_identity_id: details.identityId, - prove_phone_number: details.phoneNumber, - prove_carrier: details.carrier, - prove_line_type: details.lineType, - prove_is_active: details.active, - prove_lookup_timestamp: new Date().toISOString(), - }, - metadata_admin: { - prove_correlation_id: first.correlationId, - prove_client_customer_id: first.clientCustomerId, - prove_country_code: details.countryCode, - }, - }, - }); - } catch (err) { - console.error("Prove lookup error:", (err as Error).message); - res.json({ - identity: { - metadata_public: { prove_identity_status: "lookup_error" }, - metadata_admin: { prove_error_message: (err as Error).message }, - }, - }); - } - }, -); - -// Enroll the user's phone number in Prove's Identity Manager. +// Enroll the user's phone number in Prove's Identity Manager. Configure this +// action without response.parse so it runs after the identity is saved and +// receives the real identity ID. app.post( "/prove/enroll", verifyWebhookSecret, async (req: Request, res: Response) => { const { kratos_identity_id, phone_number } = req.body; - if (!kratos_identity_id || !phone_number) { - res.status(400).json({ error: "kratos_identity_id and phone_number are required" }); + if ( + !kratos_identity_id || + kratos_identity_id === NIL_UUID || + !phone_number + ) { + res + .status(400) + .json({ + error: "a saved kratos_identity_id and phone_number are required", + }); return; } @@ -208,39 +187,40 @@ app.post( phoneNumber: phone_number, clientCustomerId: kratos_identity_id, }); - const enrollData = - unwrap>(enrollResp, "v3EnrollIdentityResponse") ?? - (enrollResp as Record); + const enrollData = enrollResp.v3EnrollIdentityResponse; if (!enrollData || enrollData.success !== true) { - throw new Error(`Enrollment failed: ${enrollData?.verificationResult ?? "unknown error"}`); + throw new Error( + `Enrollment failed: Prove did not return a successful enrollment`, + ); } - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_identity_enrolled", value: true }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_identity_id", value: enrollData.identityId }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_verification_result", value: enrollData.verificationResult }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_enrollment_timestamp", value: new Date().toISOString() }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/prove_correlation_id", value: enrollData.correlationId }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/prove_client_customer_id", value: enrollData.clientCustomerId }, - ], + await mergeMetadata(kratos_identity_id, { + metadata_public: { + prove_identity_enrolled: true, + prove_identity_id: enrollData.identityId, + prove_enrollment_timestamp: new Date().toISOString(), + }, + metadata_admin: { + prove_client_customer_id: kratos_identity_id, + }, }); res.json({ success: true, prove_identity_id: enrollData.identityId }); } catch (err) { console.error("Prove enrollment error:", (err as Error).message); try { - await kratos.patchIdentity({ - id: kratos_identity_id, - jsonPatch: [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/prove_enrollment_error", value: (err as Error).message }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/prove_enrollment_attempted", value: new Date().toISOString() }, - ], + await mergeMetadata(kratos_identity_id, { + metadata_admin: { + prove_enrollment_error: (err as Error).message, + prove_enrollment_attempted: new Date().toISOString(), + }, }); } catch (e) { - console.error("Failed to record enrollment error:", (e as Error).message); + console.error( + "Failed to record enrollment error:", + (e as Error).message, + ); } res.json({ success: false, error: (err as Error).message }); } @@ -248,98 +228,44 @@ app.post( ); // Post-login validation: confirm the stored phone matches what Prove has. +// Ory Kratos Identities ignores identity changes in a login.after response, +// so this only decides. Users without a Prove enrollment, and Prove outages, +// are let through. app.post( "/prove/validate", verifyWebhookSecret, async (req: Request, res: Response) => { - const { kratos_identity_id, phone_number, prove_identity_id } = req.body; - if (!kratos_identity_id) { - res.status(400).json({ error: "kratos_identity_id required" }); - return; - } + const { phone_number, prove_identity_id } = req.body; if (!prove_identity_id) { - res.json({ - identity: { - metadata_public: { - prove_validation_status: "not_enrolled", - prove_last_validation: new Date().toISOString(), - }, - }, - }); + res.json({}); return; } try { - const identityResp = await prove.identity.v3GetIdentity(prove_identity_id); - const data = - unwrap>(identityResp, "v3GetIdentityResponse") ?? - (identityResp as Record); - - if (!data || data.success !== true) { - res.json({ - identity: { - metadata_public: { - prove_validation_status: "retrieval_failed", - prove_last_validation: new Date().toISOString(), - }, - metadata_admin: { prove_validation_error: "Failed to retrieve identity from Prove" }, - }, - }); + const identityResp = + await prove.identity.v3GetIdentity(prove_identity_id); + const data = identityResp.v3GetIdentityResponse; + + if (!data) { + console.warn( + `Prove identity ${prove_identity_id} could not be retrieved`, + ); + res.json({}); return; } - const provePhone = data.phoneNumber as string | undefined; + const provePhone = data.phoneNumber; if (phone_number && provePhone && provePhone !== phone_number) { - res.status(400).json({ - messages: [ - { - instance_ptr: "#/", - message: "Your phone number has changed. Please update your account information.", - type: "error", - context: { - reason: "phone_number_mismatch", - prove_phone: provePhone, - registered_phone: phone_number, - }, - }, - ], - }); + block( + res, + "We couldn't confirm your phone number. Please contact support.", + ); return; } - - const verificationResult = data.verificationResult; - const updates = [ - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_validation_status", value: "verified" }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_verification_result", value: verificationResult }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_last_validation", value: new Date().toISOString() }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_public/prove_phone_verified", value: provePhone }, - { op: JsonPatchOpEnum.Replace, path: "/metadata_admin/prove_correlation_id", value: data.correlationId }, - ]; - - await kratos.patchIdentity({ id: kratos_identity_id, jsonPatch: updates }); - - res.json({ - identity: { - metadata_public: { - prove_validation_status: "verified", - prove_verification_result: verificationResult, - prove_last_validation: new Date().toISOString(), - prove_phone_verified: provePhone, - }, - metadata_admin: { prove_correlation_id: data.correlationId }, - }, - }); + res.json({}); } catch (err) { console.error("Prove validation error:", (err as Error).message); - res.json({ - identity: { - metadata_public: { - prove_validation_status: "validation_error", - prove_last_validation: new Date().toISOString(), - }, - metadata_admin: { prove_validation_error: (err as Error).message }, - }, - }); + res.json({}); } }, ); diff --git a/identity-verification/socure/README.md b/identity-verification/socure/README.md index 7f2427c..80c3b5b 100644 --- a/identity-verification/socure/README.md +++ b/identity-verification/socure/README.md @@ -13,8 +13,8 @@ A regulated B2C product needs ML-driven identity verification at signup with str ## How it works -1. **Synchronous path** — A user completes registration; Ory fires the sync post-registration Action to `POST /socure/verify-identity`. The handler verifies the shared secret, calls Socure ID+ (`POST /api/3.0/EmailAuthScore`) with the configured `SOCURE_MODULES`, and echoes back `reference_id`, `decision` (`kyc.fieldValidations`), and `fraud_score`. Ory's response-parse Jsonnet applies policy. -2. **Asynchronous path** — When Socure is configured to send async results (long-running checks), Socure POSTs to `/socure/results-callback` with an HMAC-SHA256 signature over the raw body in `X-Socure-Signature`. The handler verifies the signature, and when `ORY_SDK_URL` + `ORY_ADMIN_API_KEY` are set, PATCHes the final `decision` to `metadata_public.socure_decision` on the matching identity. +1. **Synchronous path** — A user completes registration; Ory calls `POST /socure/verify-identity` from a registration action with `response.parse: true`, before it saves the identity. The handler verifies the shared secret and calls Socure ID+ (`POST /api/3.0/EmailAuthScore`) with the configured `SOCURE_MODULES` and its own reference as `customerUserId`. The handler applies the policy: when Socure's `decision.value` is `reject`, or the first fraud score is at or above `SOCURE_FRAUD_THRESHOLD` (default `0.9`), it responds `400` with a `messages` body and Ory rejects the registration. Otherwise it responds `200` with `{"identity": {"external_id": "", "metadata_public": {"socure_verification_status": ...}}}`. Any `200` lets the registration through. +2. **Asynchronous path** — When Socure is configured to send async results (long-running checks), Socure POSTs to `/socure/results-callback` with an HMAC-SHA256 signature over the raw body in `X-Socure-Signature`. The handler verifies the signature, finds the identity with `GET /admin/identities/by/external/` and writes the final `decision` to `metadata_public.socure_decision`. It returns 2xx only after the write succeeds, so Socure retries a failed delivery. ## Prerequisites @@ -43,9 +43,27 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory 1. In the Ory Console, configure the sync Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml). -2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). Socure benefits from a `device_session_id` collected client-side via the Socure SDK; pass it through `identity.traits.socure_device_session_id` or `transient_payload.device_session_id`. +2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). Socure benefits from a `device_session_id` collected client-side via the Socure SDK; pass it through `transient_payload.device_session_id` or `identity.traits.socure_device_session_id`. 3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the hook config. -4. For the async callback path, configure the callback URL and signing secret in Socure Admin → Webhooks, and put the same secret in `SOCURE_CALLBACK_SECRET`. Optionally set `ORY_SDK_URL` and `ORY_ADMIN_API_KEY` to enable the metadata write-back. +4. For the async callback path, configure the callback URL and signing secret in Socure Admin → Webhooks, and put the same secret in `SOCURE_CALLBACK_SECRET`, `ORY_SDK_URL`, and `ORY_ADMIN_API_KEY`. The callback returns 503 until all three are set. + +- Put the hook in the method-specific list, such as `selfservice.flows.registration.after.password.hooks`, and repeat it for every method you enable, such as `oidc` or `code`. Ory runs the global `registration.after.hooks` list only for a method that has no hooks of its own. Keep the `session` hook last. +- `body: file://./jsonnet/...` works for self-hosted Ory Kratos. On Ory Network, encode the template with `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and set `body: base64://`. +- With `response.parse: true`, the action runs before the identity is saved, so `ctx.identity.id` is `00000000-0000-0000-0000-000000000000`. The handler sends its own reference to the vendor and returns it as the identity's `external_id`; look the identity up later with `GET /admin/identities/by/external/`. +- Ory waits about 30 seconds for the handler and retries a `5xx` response (three attempts in total). + +To reject the flow, the handler responds with a `4xx` status code and this body. Ory shows the `text` to the user: + +```json +{ + "messages": [ + { + "instance_ptr": "#/", + "messages": [{ "id": 9000302, "text": "We couldn't verify your identity.", "type": "error" }] + } + ] +} +``` Identity-schema extensions for KYC fields, Socure module configuration, and per-module decision interpretation: see the [docs page](https://ory.com/docs/integrates-with/identity-verification/socure). diff --git a/identity-verification/socure/jsonnet/identity.jsonnet b/identity-verification/socure/jsonnet/identity.jsonnet index 5ab198c..bda5e73 100644 --- a/identity-verification/socure/jsonnet/identity.jsonnet +++ b/identity-verification/socure/jsonnet/identity.jsonnet @@ -1,10 +1,18 @@ -function(ctx) { - identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.first else null, - last_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.last else null, - date_of_birth: if std.objectHas(ctx.identity.traits, "date_of_birth") then ctx.identity.traits.date_of_birth else null, - ssn: if std.objectHas(ctx.identity.traits, "ssn") then ctx.identity.traits.ssn else null, - address: if std.objectHas(ctx.identity.traits, "address") then ctx.identity.traits.address else null, - device_session_id: if std.objectHas(ctx.identity.traits, "socure_device_session_id") then ctx.identity.traits.socure_device_session_id else null, -} +// The identity is not saved yet, so ctx.identity.id is the nil UUID. Send +// only values the user submitted. +function(ctx) + local traits = std.get(ctx.identity, 'traits', {}); + local name = std.get(traits, 'name', null); + local payload = std.get(ctx.flow, 'transient_payload', null); + { + email: std.get(traits, 'email', null), + first_name: if name == null then null else std.get(name, 'first', null), + last_name: if name == null then null else std.get(name, 'last', null), + date_of_birth: std.get(traits, 'date_of_birth', null), + ssn: std.get(traits, 'ssn', null), + address: std.get(traits, 'address', null), + device_session_id: + if payload != null && std.objectHas(payload, 'device_session_id') + then payload.device_session_id + else std.get(traits, 'socure_device_session_id', null), + } diff --git a/identity-verification/socure/ory-actions.yaml b/identity-verification/socure/ory-actions.yaml index a4501e8..7592092 100644 --- a/identity-verification/socure/ory-actions.yaml +++ b/identity-verification/socure/ory-actions.yaml @@ -1,15 +1,36 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/socure/verify-identity - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: false - parse: true +# Socure ID+ verification at registration. +# +# The action runs before the identity is saved (response.parse: true), so the +# handler can reject the registration or return metadata to store with the new +# identity. The password lists are aliased into the oidc, code, passkey, and +# webauthn lists, so every sign-in method runs the same hooks. Alias them into +# any other method you enable, such as saml, the same way. + +selfservice: + flows: + registration: + after: + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/socure/verify-identity + method: POST + body: file://./jsonnet/identity.jsonnet + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + parse: true + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks diff --git a/identity-verification/socure/registry.entry.yaml b/identity-verification/socure/registry.entry.yaml index 9cb11fc..37ae5c9 100644 --- a/identity-verification/socure/registry.entry.yaml +++ b/identity-verification/socure/registry.entry.yaml @@ -17,8 +17,8 @@ useCase: >- document checks — device, email, network, phone risk — and wants a single deployment that handles both fast and slow Socure verdict paths. coreFunctionality: |- - - Sync path: POST /socure/verify-identity (Ory Action target on registration.after); calls Socure ID+ POST /api/3.0/EmailAuthScore with configured SOCURE_MODULES; returns reference_id / decision (kyc.fieldValidations) / fraud_score for the response-parse Jsonnet to apply policy. - - Async path: POST /socure/results-callback (FROM Socure when async checks complete); HMAC-SHA256 over raw body via X-Socure-Signature; when ORY_SDK_URL + ORY_ADMIN_API_KEY are set, PATCHes the final decision to metadata_public.socure_decision on the matching identity. + - Sync path: POST /socure/verify-identity (registration action with response.parse: true); calls Socure ID+ POST /api/3.0/EmailAuthScore with configured SOCURE_MODULES and the handler's own reference as customerUserId; rejects the registration with a messages response when the decision is reject or the fraud score reaches SOCURE_FRAUD_THRESHOLD, otherwise returns the reference as external_id. + - Async path: POST /socure/results-callback (FROM Socure when async checks complete); HMAC-SHA256 over raw body via X-Socure-Signature; when ORY_SDK_URL + ORY_ADMIN_API_KEY are set, finds the identity by external_id and writes the final decision to metadata_public.socure_decision. - SOCURE_MODULES configures which signals run (commonly kyc, fraud, phonerisk). - Single deployment, single binary handles both paths — async config is optional. - Authenticated via shared ORY_WEBHOOK_SECRET (sync) and SOCURE_CALLBACK_SECRET (async HMAC). diff --git a/identity-verification/socure/webhook/.env.example b/identity-verification/socure/webhook/.env.example index 59cc3d4..6833773 100644 --- a/identity-verification/socure/webhook/.env.example +++ b/identity-verification/socure/webhook/.env.example @@ -10,14 +10,17 @@ SOCURE_API_KEY= # Comma-separated list of Socure ID+ modules to run, e.g. "kyc,fraud,phonerisk". SOCURE_MODULES=kyc,fraud +# Sign-up is blocked when the fraud score is at or above this value (0.0-1.0). +SOCURE_FRAUD_THRESHOLD=0.9 + # Shared secret Socure signs its async result callbacks with. # Configure this in Socure Admin -> Webhooks; the handler verifies the HMAC -# on /socure/results-callback. Leave blank to disable the callback endpoint. +# on /socure/results-callback. SOCURE_CALLBACK_SECRET= -# Optional: Ory Network SDK URL + admin API key so the async callback can -# write the final decision back to the identity's metadata_public. -# Leave both blank to log-only. +# Ory SDK URL and a project API key, used by the async callback to write the +# final decision to the identity's metadata_public. The callback endpoint +# returns 503 until these and SOCURE_CALLBACK_SECRET are set. ORY_SDK_URL= ORY_ADMIN_API_KEY= diff --git a/identity-verification/socure/webhook/server.ts b/identity-verification/socure/webhook/server.ts index 31f8b44..ca2056b 100644 --- a/identity-verification/socure/webhook/server.ts +++ b/identity-verification/socure/webhook/server.ts @@ -6,8 +6,11 @@ // POST /socure/results-callback — async callback FROM Socure with final decision // (HMAC-SHA256 signature over the raw body) // -// When ORY_SDK_URL and ORY_ADMIN_API_KEY are set, the async callback writes the -// final decision back to the identity's metadata_public via the Admin API. +// The sync call runs before Ory saves the identity, so the handler sends its own +// reference to Socure as customerUserId and returns it as the identity's +// external_id. When ORY_SDK_URL and ORY_ADMIN_API_KEY are set, the async +// callback finds the identity by that external_id and writes the final decision +// to metadata_public via the Admin API. import "dotenv/config"; import crypto from "node:crypto"; @@ -25,6 +28,9 @@ const SOCURE_MODULES = (process.env.SOCURE_MODULES || "kyc,fraud") .split(",") .map((s) => s.trim()) .filter(Boolean); +const SOCURE_FRAUD_THRESHOLD = parseFloat( + process.env.SOCURE_FRAUD_THRESHOLD || "0.9", +); const SOCURE_CALLBACK_SECRET = process.env.SOCURE_CALLBACK_SECRET; const ORY_SDK_URL = process.env.ORY_SDK_URL; const ORY_ADMIN_API_KEY = process.env.ORY_ADMIN_API_KEY; @@ -35,7 +41,6 @@ if (!ORY_WEBHOOK_SECRET || !SOCURE_API_KEY) { } interface OrySocureRequest { - identity_id?: string; email?: string; first_name?: string; last_name?: string; @@ -53,6 +58,7 @@ interface OrySocureRequest { interface SocureIdPlusResponse { referenceId?: string; + decision?: { value?: string }; kyc?: { fieldValidations?: Record }; fraud?: { scores?: Array<{ score?: number }> }; } @@ -91,6 +97,14 @@ function verifyWebhookSecret(req: Request, res: Response, next: NextFunction) { next(); } +// Ory Kratos Identities rejects the flow and shows `text` to the user only for +// a 4xx response in this shape. +function rejectFlow(res: Response, id: number, text: string) { + res.status(400).json({ + messages: [{ instance_ptr: "#/", messages: [{ id, text, type: "error" }] }], + }); +} + app.get("/health", (_req, res) => { res.json({ ok: true }); }); @@ -100,7 +114,6 @@ app.post( verifyWebhookSecret, async (req: Request, res: Response) => { const { - identity_id, email, first_name, last_name, @@ -111,35 +124,44 @@ app.post( } = req.body; if (!email || !first_name || !last_name) { - res.status(400).json({ error: "email, first_name, last_name are required" }); + rejectFlow( + res, + 9000301, + "Please provide your first name, last name, and email address.", + ); return; } + const reference = crypto.randomUUID(); + try { - const socureRes = await fetch(`${SOCURE_API_BASE}/api/3.0/EmailAuthScore`, { - method: "POST", - headers: { - // Socure's header name is camelCase per its API spec, not a typo. - SocureApiKey: SOCURE_API_KEY as string, - "content-type": "application/json", + const socureRes = await fetch( + `${SOCURE_API_BASE}/api/3.0/EmailAuthScore`, + { + method: "POST", + headers: { + // Socure's header name is camelCase per its API spec, not a typo. + SocureApiKey: SOCURE_API_KEY as string, + "content-type": "application/json", + }, + body: JSON.stringify({ + modules: SOCURE_MODULES, + firstName: first_name, + surName: last_name, + email, + dob: date_of_birth, + nationalId: ssn, + physicalAddress: address?.street1, + city: address?.city, + state: address?.state, + zip: address?.zip, + country: address?.country ?? "US", + deviceSessionId: device_session_id, + customerUserId: reference, + }), + signal: AbortSignal.timeout(15000), }, - body: JSON.stringify({ - modules: SOCURE_MODULES, - firstName: first_name, - surName: last_name, - email, - dob: date_of_birth, - nationalId: ssn, - physicalAddress: address?.street1, - city: address?.city, - state: address?.state, - zip: address?.zip, - country: address?.country ?? "US", - deviceSessionId: device_session_id, - customerUserId: identity_id, - }), - signal: AbortSignal.timeout(15000), - }); + ); if (!socureRes.ok) { console.error(`Socure ${socureRes.status}: ${await socureRes.text()}`); @@ -148,11 +170,30 @@ app.post( } const result = (await socureRes.json()) as SocureIdPlusResponse; + const decision = result.decision?.value?.toLowerCase() ?? null; + const fraudScore = result.fraud?.scores?.[0]?.score ?? null; + if ( + decision === "reject" || + (fraudScore !== null && fraudScore >= SOCURE_FRAUD_THRESHOLD) + ) { + console.log( + `Socure rejected registration: ref=${result.referenceId} decision=${decision} fraud_score=${fraudScore}`, + ); + rejectFlow(res, 9000302, "We couldn't verify your identity."); + return; + } + res.json({ - ok: true, - reference_id: result.referenceId, - decision: result.kyc?.fieldValidations ?? null, - fraud_score: result.fraud?.scores?.[0]?.score ?? null, + identity: { + external_id: reference, + metadata_public: { + socure_verification_status: decision ?? "pending", + }, + metadata_admin: { + socure_reference_id: result.referenceId ?? null, + socure_fraud_score: fraudScore, + }, + }, }); } catch (err) { console.error("Verify error:", (err as Error).message); @@ -161,11 +202,25 @@ app.post( }, ); -async function writeIdentityMetadata(identityId: string, decision: unknown): Promise { - if (!ORY_SDK_URL || !ORY_ADMIN_API_KEY) { - console.log(`Socure callback for ${identityId}: ORY_SDK_URL / ORY_ADMIN_API_KEY unset, skipping write-back`); - return; +async function writeIdentityMetadata( + reference: string, + decision: unknown, +): Promise { + const lookup = await fetch( + `${ORY_SDK_URL}/admin/identities/by/external/${encodeURIComponent(reference)}`, + { + headers: { authorization: `Bearer ${ORY_ADMIN_API_KEY}` }, + signal: AbortSignal.timeout(5000), + }, + ); + if (lookup.status === 404) { + throw new Error(`Identity with external_id ${reference} is not saved yet`); + } + if (!lookup.ok) { + const detail = await lookup.text().catch(() => ""); + throw new Error(`Ory lookup ${lookup.status}: ${detail}`); } + const { id: identityId } = (await lookup.json()) as { id: string }; const res = await fetch(`${ORY_SDK_URL}/admin/identities/${identityId}`, { method: "PATCH", headers: { @@ -173,7 +228,7 @@ async function writeIdentityMetadata(identityId: string, decision: unknown): Pro "content-type": "application/json", }, body: JSON.stringify([ - { op: "replace", path: "/metadata_public/socure_decision", value: decision }, + { op: "add", path: "/metadata_public/socure_decision", value: decision }, ]), signal: AbortSignal.timeout(5000), }); @@ -189,7 +244,7 @@ async function writeIdentityMetadata(identityId: string, decision: unknown): Pro app.post( "/socure/results-callback", async (req: Request, res: Response) => { - if (!SOCURE_CALLBACK_SECRET) { + if (!SOCURE_CALLBACK_SECRET || !ORY_SDK_URL || !ORY_ADMIN_API_KEY) { res.status(503).json({ error: "callback_disabled" }); return; } @@ -199,7 +254,10 @@ app.post( return; } const sig = req.header("x-socure-signature") ?? ""; - const expected = crypto.createHmac("sha256", SOCURE_CALLBACK_SECRET).update(rawBody).digest("hex"); + const expected = crypto + .createHmac("sha256", SOCURE_CALLBACK_SECRET) + .update(rawBody) + .digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { @@ -207,21 +265,29 @@ app.post( return; } - const identityId = req.body.customerUserId; + const reference = req.body.customerUserId; const decision = req.body.decision; - console.log(`Socure async result: ref=${req.body.referenceId} identity=${identityId}`); + console.log( + `Socure async result: ref=${req.body.referenceId} customerUserId=${reference}`, + ); - if (identityId) { - try { - await writeIdentityMetadata(identityId, decision); - } catch (err) { - console.error(`Socure callback write-back failed: ${(err as Error).message}`); - // Still 200 to Socure — they retry on 5xx but the callback is logged - // and can be replayed manually from the Socure dashboard. - } + if (!reference) { + res.json({ ok: true, ignored: true }); + return; + } + + // The callback is acknowledged only after the identity is updated, so + // Socure retries a delivery that failed to apply. + try { + await writeIdentityMetadata(reference, decision); + res.json({ ok: true }); + } catch (err) { + console.error( + `Socure callback write-back failed: ${(err as Error).message}`, + ); + res.status(500).json({ error: "processing_failed" }); } - res.json({ ok: true }); }, ); diff --git a/mfa/duo-security/README.md b/mfa/duo-security/README.md index 345e1ef..2a50be6 100644 --- a/mfa/duo-security/README.md +++ b/mfa/duo-security/README.md @@ -2,38 +2,63 @@ > **Maintained by:** Community contributors -[Duo Security](https://duo.com) (a Cisco company) is a market-leading enterprise MFA platform with strong push and passcode flows. This integration sends a Duo push challenge from an Ory Action webhook on login, polls Duo for the user's response, and returns the verdict so Ory can step up authentication or fail the flow. +[Duo Security](https://duo.com) (a Cisco company) is an enterprise MFA platform with push and passcode flows. This integration verifies a Duo passcode from an Ory Action webhook at sign-in and blocks the sign-in unless Duo allows it. **Type:** webhook (Ory Action calls a handler during a flow) **Docs page:** [ory.com/docs/integrates-with/mfa/duo-security](https://www.ory.com/docs/integrates-with/mfa/duo-security) ## Use case -An enterprise has already standardized its workforce on Duo Mobile for MFA and wants to reuse that enrollment from Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. The integration runs Duo's push challenge inline with Ory login; if the user approves on their device, Ory issues the session, otherwise the flow fails. +An enterprise has already standardized its workforce on Duo for MFA and wants to reuse that enrollment from Ory-hosted login flows without re-enrolling users in Ory's native TOTP or WebAuthn. + +## Before you start + +Ory Kratos Identities has built-in second factors: [WebAuthn and passkeys](https://www.ory.com/docs/kratos/mfa/webauthn-fido-yubikey), [TOTP](https://www.ory.com/docs/kratos/mfa/totp), lookup secrets, and one-time codes. They run inside the login flow and raise the session to AAL2. Use them unless you must keep Duo as the second factor. + +An Ory Action is a single HTTP request from Ory to this handler: + +- Ory waits about 30 seconds for a response. +- Ory retries a `5xx` response, three attempts in total. The handler reports every failure as a `4xx` so a retry never repeats a vendor call. +- Ory doesn't relay the webhook response to your UI. +- A factor checked by an Ory Action doesn't raise the session's AAL. The session stays at AAL1. + +For these reasons the handler checks a one-time code that the login UI collects together with the primary credentials, and doesn't wait for a push. To use Duo Push or the Duo Universal Prompt, run it from your application after Ory issues the session. ## How it works -1. A user submits credentials in the Ory UI. -2. Ory fires the sync post-login Action webhook to this handler. The handler verifies the shared secret. -3. The handler signs a Duo Auth API request with HMAC-SHA1 over a canonicalized request line and POSTs to `/auth/v2/auth` with `factor=push`, `async=1`, and the user's `duo_username` (defaults to identity email — override per identity by mapping a `traits.duo_username` field). -4. Duo returns a `txid`. The handler long-polls `/auth/v2/auth_status?txid=...` (each call blocks server-side for up to ~30 s) until the user responds or `DUO_POLL_DEADLINE_SECONDS` (default 45 s) elapses. -5. The handler returns `{ result, mfa_passed }`. Ory's response-parse Jsonnet consumes the verdict and gates the flow. +1. The login UI collects a Duo passcode together with the primary credentials and sends it in the flow's `transient_payload` as `duo_passcode`. +2. After the primary credentials succeed, Ory calls this handler with the identity ID as the Duo username, the passcode, and the client IP from the `True-Client-Ip` header. +3. The handler signs a Duo Auth API request with HMAC-SHA1 and POSTs to `/auth/v2/auth` with `factor=passcode`. Duo answers immediately with `allow` or `deny`. +4. The handler responds `200` on `allow` and blocks the sign-in otherwise. + +To block the sign-in, the handler responds with `403` and a body that Ory Kratos Identities shows to the user: + +```json +{ + "messages": [ + { + "instance_ptr": "#/", + "messages": [{ "id": 9001004, "text": "Duo verification failed.", "type": "error" }] + } + ] +} +``` + +On success, it responds with `200` and `{}`. ## Prerequisites - An Ory Network project. -- Access to the Duo Admin Panel to create an **Auth API** application — get `DUO_API_HOST`, `DUO_INTEGRATION_KEY`, `DUO_SECRET_KEY`. -- Users already enrolled in Duo (this handler does **not** enroll users). -- A `duo_username` per Ory identity — either fall back to `email` (default in [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet)) or store a vendor-specific username in `identity.traits.duo_username`. -- A deployment target for the webhook handler (any Node.js runtime: Cloud Run, Heroku, Vercel, Lambda behind API Gateway, your own VM). +- A Duo **Auth API** application in the Duo Admin Panel, with `DUO_API_HOST`, `DUO_INTEGRATION_KEY`, and `DUO_SECRET_KEY`. +- Users enrolled in Duo with the Ory identity ID as their Duo username. This handler doesn't enroll users. +- A deployment target for the webhook handler. ## Deploy the webhook handler ```bash cd webhook/ cp .env.example .env -# Fill ORY_WEBHOOK_SECRET, DUO_API_HOST, DUO_INTEGRATION_KEY, DUO_SECRET_KEY, -# and (optionally) DUO_POLL_DEADLINE_SECONDS. +# Fill ORY_WEBHOOK_SECRET, DUO_API_HOST, DUO_INTEGRATION_KEY, DUO_SECRET_KEY. npm install npm start ``` @@ -41,26 +66,21 @@ npm start The server listens on the port specified in `.env` (default 3000) and exposes: - `GET /health` — readiness check. -- `POST /duo/challenge` — Ory Action target. +- `POST /duo/verify` — Ory Action target. ## Configure Ory -1. In the Ory Console, configure the Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml) — register it on the **after** stage of your login flow. -2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). The default falls back to `email` as the Duo username; adjust the schema and Jsonnet if Duo uses a different identifier in your org. -3. Keep `response.parse: true` and `response.ignore: false` so Ory consumes `mfa_passed` and gates the flow. -4. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the hook config. -5. Make sure Ory's webhook total timeout is ≥ `DUO_POLL_DEADLINE_SECONDS` + headroom; the long-poll is intentionally slow. - -Duo Auth API onboarding, ipaddr/risk signal extensions, and step-up enforcement strategies: see the [docs page](https://ory.com/docs/integrates-with/mfa/duo-security). +1. Encode the body template with `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and put the result in the `body` of the hook in [`ory-actions.yaml`](ory-actions.yaml). +2. Merge [`ory-actions.yaml`](ory-actions.yaml) into the identity configuration. It puts the hook in `selfservice.flows.login.after.password.hooks` with `response.parse: true` and aliases that list into the `oidc`, `code`, `passkey`, and `webauthn` lists. Add any other login method you enable, such as `saml`, the same way. Ory Kratos Identities runs the global `selfservice.flows.login.after.hooks` list only for a method that has no hooks of its own. +3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. +4. In your login UI, send the code in the flow's `transient_payload` as ``duo_passcode``. ## Troubleshooting - **`401 invalid webhook secret`** — `ORY_WEBHOOK_SECRET` in `.env` doesn't match `X-Webhook-Secret` in the Ory hook config. -- **`401` from Duo with `Invalid signature`** — clock drift between the handler and Duo > 30 s. Run NTP on the handler host. -- **`User does not exist` from Duo** — `duo_username` doesn't match a Duo user. Confirm the trait mapping matches what's enrolled in Duo. -- **Push never arrives** — confirm the user has the Duo Mobile app installed and push notifications are enabled. Try sending a test push from the Duo Admin Panel. -- **`502 duo_status_failed`** — long-poll connectivity issue (egress blocked, DNS, allowlist). Duo's IP allowlist is per-customer; egress to `api-XXXXXXXX.duosecurity.com` from the handler's network is required. -- **`result: "waiting"` returned to Ory** — the user didn't respond before `DUO_POLL_DEADLINE_SECONDS`. Bump the deadline (within Duo's ~60 s push-session limit) or fail-closed on `waiting` in your response-parse Jsonnet. +- **"Duo verification is unavailable"** — the Duo call failed. Check the handler log. A `401` from Duo with `Invalid signature` usually means clock drift between the handler and Duo; run NTP on the handler host. +- **Every sign-in is denied** — the Duo username doesn't match. Duo users must be enrolled with the Ory identity ID as username. +- **"Enter your Duo passcode"** — the login UI didn't send `duo_passcode` in `transient_payload`. ## License diff --git a/mfa/duo-security/jsonnet/identity.jsonnet b/mfa/duo-security/jsonnet/identity.jsonnet index b6009af..793c1ef 100644 --- a/mfa/duo-security/jsonnet/identity.jsonnet +++ b/mfa/duo-security/jsonnet/identity.jsonnet @@ -1,7 +1,9 @@ -function(ctx) { - identity_id: ctx.identity.id, - // Duo identifies users by username, not email — you must enroll the user in Duo - // ahead of time and put the matching username in identity.traits.duo_username. - duo_username: if std.objectHas(ctx.identity.traits, "duo_username") then ctx.identity.traits.duo_username else ctx.identity.traits.email, - client_ip: ctx.request_headers["X-Forwarded-For"][0], -} +// The login UI sends the Duo passcode in the flow's transient_payload under +// `duo_passcode`. Ory Network sets the client IP in the True-Client-Ip header. +function(ctx) + local payload = std.get(ctx.flow, 'transient_payload', null); + { + username: ctx.identity.id, + passcode: if payload == null then null else std.get(payload, 'duo_passcode', null), + ipaddr: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], + } diff --git a/mfa/duo-security/ory-actions.yaml b/mfa/duo-security/ory-actions.yaml index 95aa8f1..b3921dc 100644 --- a/mfa/duo-security/ory-actions.yaml +++ b/mfa/duo-security/ory-actions.yaml @@ -1,15 +1,34 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/duo/challenge - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: false - parse: true +# Merge into the identity configuration. The password lists are aliased into +# the oidc, code, passkey, and webauthn lists, so every sign-in method runs +# the same hooks. Alias them into any other method you enable, such as saml, +# the same way. Encode jsonnet/identity.jsonnet with: +# +# base64 < jsonnet/identity.jsonnet | tr -d '\n' + +selfservice: + flows: + login: + after: + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/duo/verify + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + parse: true + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/mfa/duo-security/registry.entry.yaml b/mfa/duo-security/registry.entry.yaml index ab3d0b0..3045961 100644 --- a/mfa/duo-security/registry.entry.yaml +++ b/mfa/duo-security/registry.entry.yaml @@ -8,19 +8,17 @@ category: mfa type: webhook maintainedBy: Community contributors description: >- - Duo Security (a Cisco company) is an enterprise MFA platform with strong push and passcode flows. This integration - sends a Duo push challenge from an Ory Action webhook on login, polls Duo for the user's response, and returns the - verdict so Ory can step up authentication or fail the flow. + Duo Security (a Cisco company) is an enterprise MFA platform with push and passcode flows. This integration + verifies a Duo passcode from an Ory Action webhook at sign-in and blocks the sign-in unless Duo allows it. useCase: >- - An enterprise has already standardized its workforce on Duo Mobile for MFA and wants to reuse that enrollment from - Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. Duo's push challenge runs inline - with Ory login; the integration relays the verdict so Ory can issue the session or fail the flow. + An enterprise has already standardized its workforce on Duo for MFA and wants to reuse that enrollment from + Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. coreFunctionality: |- - - Sync post-login Ory Action webhook (response.parse: true, can_interrupt: true). + - Sync post-login Ory Action webhook in the method-specific login hook lists (response.parse: true). + - Reads the Duo passcode from the flow's transient_payload and the client IP from True-Client-Ip. - Signs Duo Auth API requests with HMAC-SHA1 over a canonicalized request line per Duo's spec. - - POSTs /auth/v2/auth (factor=push, async=1) and long-polls /auth/v2/auth_status?txid=... until the user responds or DUO_POLL_DEADLINE_SECONDS elapses. - - Returns { result: allow | deny | waiting, mfa_passed } so the post-flow Action can gate the session. - - 502 on Duo errors so a Duo outage never silently grants MFA. + - POSTs /auth/v2/auth with factor=passcode; Duo answers immediately with allow or deny. + - Responds 200 on allow and 403 with Ory UI messages otherwise, so Duo errors never grant access and Ory never retries. oryMechanism: - actions protocol: diff --git a/mfa/duo-security/webhook/.env.example b/mfa/duo-security/webhook/.env.example index e9aff7a..28616b3 100644 --- a/mfa/duo-security/webhook/.env.example +++ b/mfa/duo-security/webhook/.env.example @@ -6,9 +6,6 @@ DUO_API_HOST= DUO_INTEGRATION_KEY= DUO_SECRET_KEY= -# Push challenge poll deadline (seconds). Duo push sessions expire after ~60s by default. -DUO_POLL_DEADLINE_SECONDS=45 - # Shared secret with Ory's webhook configuration (X-Webhook-Secret header value). ORY_WEBHOOK_SECRET= diff --git a/mfa/duo-security/webhook/server.ts b/mfa/duo-security/webhook/server.ts index ea5bdc9..b616bfc 100644 --- a/mfa/duo-security/webhook/server.ts +++ b/mfa/duo-security/webhook/server.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // // Reference webhook handler for the Ory <> Duo Security integration. -// Sends a Duo push, polls /auth_status until the user responds (or the deadline -// elapses), and returns the verdict so an Ory Action can step up authentication. +// Verifies a Duo passcode that the login UI collected with the primary +// credentials and blocks the sign-in unless Duo allows it. import "dotenv/config"; import crypto from "node:crypto"; @@ -17,8 +17,6 @@ const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; const DUO_API_HOST = process.env.DUO_API_HOST; const DUO_INTEGRATION_KEY = process.env.DUO_INTEGRATION_KEY; const DUO_SECRET_KEY = process.env.DUO_SECRET_KEY; -const POLL_DEADLINE_MS = - Number.parseInt(process.env.DUO_POLL_DEADLINE_SECONDS || "45", 10) * 1000; if (!ORY_WEBHOOK_SECRET || !DUO_API_HOST || !DUO_INTEGRATION_KEY || !DUO_SECRET_KEY) { console.error( @@ -28,9 +26,9 @@ if (!ORY_WEBHOOK_SECRET || !DUO_API_HOST || !DUO_INTEGRATION_KEY || !DUO_SECRET_ } interface OryDuoRequest { - duo_username?: string; - client_ip?: string; - status_msg?: string; + username?: string; + passcode?: string | null; + ipaddr?: string | null; } interface DuoSignedHeaders { @@ -43,8 +41,9 @@ interface DuoCallResult { status: number; body: { response?: { - txid?: string; result?: string; + status?: string; + status_msg?: string; }; [key: string]: unknown; }; @@ -125,60 +124,60 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); +// Ory Kratos Identities shows these messages to the user when the handler +// responds with a 4xx status code. Ory retries a 5xx, so every failure after +// the passcode reached Duo is reported as a 4xx. +function block(res: Response, status: number, id: number, text: string) { + res.status(status).json({ + messages: [ + { + instance_ptr: "#/", + messages: [{ id, text, type: "error" }], + }, + ], + }); +} + app.post( - "/duo/challenge", + "/duo/verify", verifyWebhookSecret, async (req: Request, res: Response) => { - const { duo_username, client_ip } = req.body; + const { username, passcode, ipaddr } = req.body ?? {}; - if (!duo_username) { - res.status(400).json({ error: "duo_username is required" }); + if (!username) { + block(res, 400, 9001001, "Duo verification failed. Please contact support."); + return; + } + if (!passcode) { + block(res, 403, 9001002, "Enter your Duo passcode."); return; } try { - // Initiate the push asynchronously so we can poll for the response. - const init = await duoCall("POST", "/auth/v2/auth", { - username: duo_username, - factor: "push", - device: "auto", - async: "1", - ipaddr: client_ip || "0.0.0.0", - }); - - const txid = init.body?.response?.txid; - if (!init.ok || !txid) { - console.error(`Duo auth init ${init.status}: ${JSON.stringify(init.body)}`); - res.status(502).json({ error: "duo_init_failed" }); + const params: Record = { + username, + factor: "passcode", + passcode, + }; + if (ipaddr) params.ipaddr = ipaddr; + + const auth = await duoCall("POST", "/auth/v2/auth", params); + const result = auth.body?.response?.result; + if (!auth.ok || !result) { + console.error(`Duo auth ${auth.status}: ${JSON.stringify(auth.body)}`); + block(res, 403, 9001003, "Duo verification is unavailable. Please try again."); return; } - const deadline = Date.now() + POLL_DEADLINE_MS; - let result: string | undefined = "waiting"; - - // Long-poll loop. Duo's /auth_status blocks server-side until state - // changes or its own internal timeout fires (~30s), then returns. We - // loop until our local deadline. - while (Date.now() < deadline) { - const status = await duoCall("GET", "/auth/v2/auth_status", { txid }); - if (!status.ok) { - console.error(`Duo auth_status ${status.status}: ${JSON.stringify(status.body)}`); - res.status(502).json({ error: "duo_status_failed" }); - return; - } - result = status.body?.response?.result; - if (result && result !== "waiting") break; + if (result !== "allow") { + block(res, 403, 9001004, "Duo verification failed."); + return; } - res.json({ - ok: true, - result, // "allow", "deny", "waiting" (if deadline hit) - status_msg: req.body?.status_msg ?? null, - mfa_passed: result === "allow", - }); + res.status(200).json({}); } catch (err) { console.error("Duo error:", (err as Error).message); - res.status(502).json({ error: "duo_failed" }); + block(res, 403, 9001003, "Duo verification is unavailable. Please try again."); } }, ); diff --git a/mfa/okta-verify/README.md b/mfa/okta-verify/README.md index e86a099..0d80329 100644 --- a/mfa/okta-verify/README.md +++ b/mfa/okta-verify/README.md @@ -2,31 +2,60 @@ > **Maintained by:** Community contributors -[Okta Verify](https://www.okta.com/products/multi-factor-authentication/) is Okta's first-party MFA app supporting push notifications and TOTP. This integration sends an Okta Verify push from an Ory Action webhook on login, polls Okta for the user's response, and returns the verdict so Ory can step up authentication or fail the flow. +[Okta Verify](https://www.okta.com/products/multi-factor-authentication/) is Okta's first-party MFA app supporting push notifications and one-time codes. This integration verifies an Okta Verify code from an Ory Action webhook at sign-in and blocks the sign-in unless Okta accepts it. **Type:** webhook (Ory Action calls a handler during a flow) **Docs page:** [ory.com/docs/integrates-with/mfa/okta-verify](https://www.ory.com/docs/integrates-with/mfa/okta-verify) ## Use case -An enterprise has already standardized its workforce on Okta Verify for MFA and wants to reuse that factor enrollment from Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. The integration runs the Okta Verify push inline with Ory login and relays the verdict so Ory issues the session only on approval. +An enterprise has already standardized its workforce on Okta Verify and wants to reuse that enrollment from Ory-hosted login flows without re-enrolling users in Ory's native TOTP or WebAuthn. + +## Before you start + +Ory Kratos Identities has built-in second factors: [WebAuthn and passkeys](https://www.ory.com/docs/kratos/mfa/webauthn-fido-yubikey), [TOTP](https://www.ory.com/docs/kratos/mfa/totp), lookup secrets, and one-time codes. They run inside the login flow and raise the session to AAL2. Use them unless you must keep Okta Verify as the second factor. + +An Ory Action is a single HTTP request from Ory to this handler: + +- Ory waits about 30 seconds for a response. +- Ory retries a `5xx` response, three attempts in total. The handler reports every failure as a `4xx` so a retry never repeats a vendor call. +- Ory doesn't relay the webhook response to your UI. +- A factor checked by an Ory Action doesn't raise the session's AAL. The session stays at AAL1. + +For these reasons the handler checks a one-time code that the login UI collects together with the primary credentials, and doesn't wait for a push. To use Okta Verify push, run it from your application after Ory issues the session. ## How it works -1. A user submits credentials in the Ory UI. -2. Ory fires the sync post-login Action webhook to this handler. The handler verifies the shared secret. -3. The handler calls `GET /api/v1/users/{user_id}/factors` and finds the active `push` factor on the Okta user. -4. The handler `POST`s `/api/v1/users/{user_id}/factors/{factor_id}/verify` to send the push. Okta returns immediately with `factorResult: WAITING` and a `_links.poll.href`. -5. The handler polls the poll URL every 2 s until `factorResult` becomes `SUCCESS`/`REJECTED`/`TIMEOUT` or `OKTA_POLL_DEADLINE_SECONDS` elapses. -6. The handler returns `{ result, mfa_passed }` to Ory; Ory's response-parse Jsonnet gates the flow on `mfa_passed`. +1. The login UI collects the Okta Verify code together with the primary credentials and sends it in the flow's `transient_payload` as `okta_pass_code`. +2. After the primary credentials succeed, Ory calls this handler with the identity ID, `metadata_public.okta_user_id`, and the code. +3. The handler finds the user's active Okta Verify code factor (`token:software:totp`, provider `OKTA`) with `GET /api/v1/users/{userId}/factors`. +4. The handler POSTs `{"passCode": "..."}` to `/api/v1/users/{userId}/factors/{factorId}/verify`. Okta answers immediately. +5. The handler responds `200` on `SUCCESS` and blocks the sign-in otherwise. + +Users without an `okta_user_id` or without an active Okta Verify code factor are unenrolled. By default the handler blocks them with a message that tells them to enroll. Set `OKTA_ALLOW_UNENROLLED=true` to let them sign in without the check. + +To block the sign-in, the handler responds with `403` and a body that Ory Kratos Identities shows to the user: + +```json +{ + "messages": [ + { + "instance_ptr": "#/", + "messages": [{ "id": 9002003, "text": "The Okta Verify code is invalid.", "type": "error" }] + } + ] +} +``` + +On success, it responds with `200` and `{}`. ## Prerequisites - An Ory Network project. - An Okta org URL (`https://your-org.okta.com`). -- An Okta API token (SSWS) with permission to read user factors and call factor verify (Okta admin → Security → API → Tokens). -- Users already enrolled in Okta Verify (or any active `push` factor on Okta). -- An `okta_user_id` per Ory identity (typically populated when the user federates from Okta or set by a provisioning flow). +- An Okta API token (SSWS) with permission to read user factors and verify them. +- Users enrolled in Okta Verify. +- `metadata_public.okta_user_id` on each Ory identity, set at enrollment through the Ory Admin API. The webhook payload doesn't include `metadata_admin`. - A deployment target for the webhook handler. ## Deploy the webhook handler @@ -35,7 +64,7 @@ An enterprise has already standardized its workforce on Okta Verify for MFA and cd webhook/ cp .env.example .env # Fill ORY_WEBHOOK_SECRET, OKTA_ORG_URL, OKTA_API_TOKEN, -# and (optionally) OKTA_POLL_DEADLINE_SECONDS. +# and optionally OKTA_ALLOW_UNENROLLED=true. npm install npm start ``` @@ -43,25 +72,21 @@ npm start The server listens on the port specified in `.env` (default 3000) and exposes: - `GET /health` — readiness check. -- `POST /okta-verify/challenge` — Ory Action target. +- `POST /okta-verify/verify` — Ory Action target. ## Configure Ory -1. Configure the Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml) — register on `after:login`. -2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). Carry `okta_user_id` in `identity.traits` (or in `metadata_admin` if you don't want to expose it). -3. Keep `response.parse: true` and `response.ignore: false` so Ory consumes `mfa_passed`. -4. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. -5. Make sure Ory's webhook total timeout is ≥ `OKTA_POLL_DEADLINE_SECONDS` + headroom. - -Token rotation, fallback factor flows, and "no factor enrolled" branching: see the [docs page](https://ory.com/docs/integrates-with/mfa/okta-verify). +1. Encode the body template with `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and put the result in the `body` of the hook in [`ory-actions.yaml`](ory-actions.yaml). +2. Merge [`ory-actions.yaml`](ory-actions.yaml) into the identity configuration. It puts the hook in `selfservice.flows.login.after.password.hooks` with `response.parse: true` and aliases that list into the `oidc`, `code`, `passkey`, and `webauthn` lists. Add any other login method you enable, such as `saml`, the same way. Ory Kratos Identities runs the global `selfservice.flows.login.after.hooks` list only for a method that has no hooks of its own. +3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. +4. In your login UI, send the code in the flow's `transient_payload` as ``okta_pass_code``. ## Troubleshooting - **`401 invalid webhook secret`** — `ORY_WEBHOOK_SECRET` in `.env` doesn't match `X-Webhook-Secret` in the Ory hook config. -- **`401 Unauthorized` from Okta** — SSWS token revoked, expired, or scoped to the wrong org. Confirm the token's permissions in the Okta admin. -- **`404` on user factor lookup** — `okta_user_id` is wrong or the Okta user was deleted. The Ory↔Okta mapping isn't current. -- **`mfa_passed: false, reason: "no_push_factor_enrolled"`** — the user has no active `push` factor. Either enroll the user in Okta Verify first or branch to a fallback factor in Ory. -- **`REJECTED` even on tap-Approve** — a stale push remained active. Tighten `OKTA_POLL_DEADLINE_SECONDS` so a stale challenge doesn't dominate the response. +- **"Okta Verify is unavailable"** — the Okta call failed. Check the handler log; a `401` from Okta means the SSWS token is revoked, expired, or for another org. +- **"Set up Okta Verify before you sign in"** — the identity has no `metadata_public.okta_user_id`, the Okta user doesn't exist, or the user has no active Okta Verify code factor. +- **"Enter the code from Okta Verify"** — the login UI didn't send `okta_pass_code` in `transient_payload`. ## License diff --git a/mfa/okta-verify/jsonnet/identity.jsonnet b/mfa/okta-verify/jsonnet/identity.jsonnet index b772396..1ceeab6 100644 --- a/mfa/okta-verify/jsonnet/identity.jsonnet +++ b/mfa/okta-verify/jsonnet/identity.jsonnet @@ -1,6 +1,10 @@ -function(ctx) { - identity_id: ctx.identity.id, - // The user must already be enrolled in Okta Verify; identity.traits.okta_user_id - // holds the Okta user ID that owns the factor. - okta_user_id: if std.objectHas(ctx.identity.traits, "okta_user_id") then ctx.identity.traits.okta_user_id else null, -} +// The login UI sends the Okta Verify code in the flow's transient_payload under +// `okta_pass_code`. metadata_public is null until something writes to it. +function(ctx) + local metadata = std.get(ctx.identity, 'metadata_public', null); + local payload = std.get(ctx.flow, 'transient_payload', null); + { + identity_id: ctx.identity.id, + okta_user_id: if metadata == null then null else std.get(metadata, 'okta_user_id', null), + pass_code: if payload == null then null else std.get(payload, 'okta_pass_code', null), + } diff --git a/mfa/okta-verify/ory-actions.yaml b/mfa/okta-verify/ory-actions.yaml index a98655a..7ef49a1 100644 --- a/mfa/okta-verify/ory-actions.yaml +++ b/mfa/okta-verify/ory-actions.yaml @@ -1,15 +1,34 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/okta-verify/challenge - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: false - parse: true +# Merge into the identity configuration. The password lists are aliased into +# the oidc, code, passkey, and webauthn lists, so every sign-in method runs +# the same hooks. Alias them into any other method you enable, such as saml, +# the same way. Encode jsonnet/identity.jsonnet with: +# +# base64 < jsonnet/identity.jsonnet | tr -d '\n' + +selfservice: + flows: + login: + after: + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/okta-verify/verify + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + parse: true + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks diff --git a/mfa/okta-verify/registry.entry.yaml b/mfa/okta-verify/registry.entry.yaml index 0877a07..00edc98 100644 --- a/mfa/okta-verify/registry.entry.yaml +++ b/mfa/okta-verify/registry.entry.yaml @@ -8,20 +8,17 @@ category: mfa type: webhook maintainedBy: Community contributors description: >- - Okta Verify is Okta's first-party MFA app supporting push notifications and TOTP. This integration sends an Okta - Verify push from an Ory Action webhook on login, polls Okta for the user's response, and returns the verdict so Ory - can step up authentication or fail the flow. + Okta Verify is Okta's first-party MFA app supporting push notifications and one-time codes. This integration + verifies an Okta Verify code from an Ory Action webhook at sign-in and blocks the sign-in unless Okta accepts it. useCase: >- - An enterprise has already standardized its workforce on Okta Verify for MFA and wants to reuse that factor enrollment - from Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. Okta Verify push runs inline - with Ory login and relays the verdict so Ory issues the session only on approval. + An enterprise has already standardized its workforce on Okta Verify and wants to reuse that enrollment from + Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. coreFunctionality: |- - - Sync post-login Ory Action webhook (response.parse: true, can_interrupt: true). - - GET /api/v1/users/{user_id}/factors → finds the active push factor. - - POST /api/v1/users/{user_id}/factors/{factor_id}/verify → sends the push, gets a poll URL. - - Polls the poll URL every 2 s until factorResult ≠ WAITING or OKTA_POLL_DEADLINE_SECONDS elapses. - - Returns { result: SUCCESS / REJECTED / TIMEOUT / WAITING, mfa_passed } so the post-flow Action can gate the session. - - 502 on Okta errors (SSWS revoked, factor lookup 404) so Okta outages never silently grant MFA. + - Sync post-login Ory Action webhook in the method-specific login hook lists (response.parse: true). + - Reads the Okta user ID from metadata_public.okta_user_id and the code from the flow's transient_payload. + - GET /api/v1/users/{userId}/factors → finds the active Okta Verify code factor (token:software:totp). + - POST /api/v1/users/{userId}/factors/{factorId}/verify with passCode; Okta answers immediately. + - Responds 200 on SUCCESS and 403 with Ory UI messages otherwise; unenrolled users are blocked unless OKTA_ALLOW_UNENROLLED=true. oryMechanism: - actions protocol: diff --git a/mfa/okta-verify/webhook/.env.example b/mfa/okta-verify/webhook/.env.example index 18f078d..2e01376 100644 --- a/mfa/okta-verify/webhook/.env.example +++ b/mfa/okta-verify/webhook/.env.example @@ -2,12 +2,12 @@ OKTA_ORG_URL= # Okta API token (SSWS). Generate under Security → API → Tokens. -# Token must have permission to read user factors and verify push factors. +# Token must have permission to read user factors and verify them. OKTA_API_TOKEN= -# Push challenge poll deadline (seconds). Okta Verify push expires after ~5 min; -# tighten to your UX expectation (default 60s). -OKTA_POLL_DEADLINE_SECONDS=60 +# Set to true to let users without an enrolled Okta Verify factor sign in. +# Unenrolled users are blocked by default. +OKTA_ALLOW_UNENROLLED=false # Shared secret with Ory's webhook configuration (X-Webhook-Secret header value). ORY_WEBHOOK_SECRET= diff --git a/mfa/okta-verify/webhook/server.ts b/mfa/okta-verify/webhook/server.ts index 9f40427..b63811b 100644 --- a/mfa/okta-verify/webhook/server.ts +++ b/mfa/okta-verify/webhook/server.ts @@ -1,9 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // // Reference webhook handler for the Ory <> Okta Verify integration. -// Triggers an Okta Verify push factor and polls until the user responds (or -// the deadline elapses), then returns the verdict so an Ory Action can step -// up authentication or fail the flow. +// Verifies an Okta Verify one-time code that the login UI collected with the +// primary credentials and blocks the sign-in unless Okta accepts it. import "dotenv/config"; import crypto from "node:crypto"; @@ -17,27 +16,28 @@ const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; const OKTA_ORG_URL = process.env.OKTA_ORG_URL; // e.g. https://your-org.okta.com const OKTA_API_TOKEN = process.env.OKTA_API_TOKEN; -const POLL_DEADLINE_MS = - Number.parseInt(process.env.OKTA_POLL_DEADLINE_SECONDS || "60", 10) * 1000; +const OKTA_ALLOW_UNENROLLED = process.env.OKTA_ALLOW_UNENROLLED === "true"; if (!ORY_WEBHOOK_SECRET || !OKTA_ORG_URL || !OKTA_API_TOKEN) { console.error("ORY_WEBHOOK_SECRET, OKTA_ORG_URL, OKTA_API_TOKEN must be set"); process.exit(1); } -interface OktaChallengeRequest { - okta_user_id?: string; +interface OktaVerifyRequest { + identity_id?: string; + okta_user_id?: string | null; + pass_code?: string | null; } interface OktaFactor { id: string; factorType: string; + provider: string; status: string; } interface OktaVerifyResponse { factorResult?: string; - _links?: { poll?: { href?: string } }; } const app = express(); @@ -60,17 +60,49 @@ const oktaHeaders: Record = { "content-type": "application/json", }; -async function findPushFactorId(userId: string): Promise { - const res = await fetch(`${OKTA_ORG_URL}/api/v1/users/${userId}/factors`, { - headers: oktaHeaders, - signal: AbortSignal.timeout(10000), - }); +class UnenrolledError extends Error {} + +// Okta Verify registers its one-time code as a software TOTP factor. +async function findTotpFactorId(userId: string): Promise { + const res = await fetch( + `${OKTA_ORG_URL}/api/v1/users/${encodeURIComponent(userId)}/factors`, + { headers: oktaHeaders, signal: AbortSignal.timeout(8000) }, + ); + if (res.status === 404) throw new UnenrolledError("okta user not found"); if (!res.ok) { throw new Error(`okta factors ${res.status}: ${await res.text()}`); } const factors = (await res.json()) as OktaFactor[]; - const push = factors.find((f) => f.factorType === "push" && f.status === "ACTIVE"); - return push?.id ?? null; + const totp = factors.find( + (f) => + f.factorType === "token:software:totp" && + f.provider === "OKTA" && + f.status === "ACTIVE", + ); + if (!totp) throw new UnenrolledError("no active Okta Verify code factor"); + return totp.id; +} + +// Ory Kratos Identities shows these messages to the user when the handler +// responds with a 4xx status code. Ory retries a 5xx, so failures are reported +// as a 4xx. +function block(res: Response, id: number, text: string) { + res.status(403).json({ + messages: [ + { + instance_ptr: "#/", + messages: [{ id, text, type: "error" }], + }, + ], + }); +} + +function unenrolled(res: Response) { + if (OKTA_ALLOW_UNENROLLED) { + res.status(200).json({}); + return; + } + block(res, 9002001, "Set up Okta Verify before you sign in."); } app.get("/health", (_req, res) => { @@ -78,67 +110,57 @@ app.get("/health", (_req, res) => { }); app.post( - "/okta-verify/challenge", + "/okta-verify/verify", verifyWebhookSecret, - async (req: Request, res: Response) => { - const { okta_user_id } = req.body; + async (req: Request, res: Response) => { + const { okta_user_id, pass_code } = req.body ?? {}; if (!okta_user_id) { - res.status(400).json({ error: "okta_user_id is required" }); + unenrolled(res); return; } try { - const factorId = await findPushFactorId(okta_user_id); - if (!factorId) { - res.json({ ok: true, mfa_passed: false, reason: "no_push_factor_enrolled" }); + const factorId = await findTotpFactorId(okta_user_id); + if (!pass_code) { + block(res, 9002002, "Enter the code from Okta Verify."); return; } const verifyRes = await fetch( - `${OKTA_ORG_URL}/api/v1/users/${okta_user_id}/factors/${factorId}/verify`, - { method: "POST", headers: oktaHeaders, signal: AbortSignal.timeout(10000) }, + `${OKTA_ORG_URL}/api/v1/users/${encodeURIComponent(okta_user_id)}/factors/${encodeURIComponent(factorId)}/verify`, + { + method: "POST", + headers: oktaHeaders, + body: JSON.stringify({ passCode: pass_code }), + signal: AbortSignal.timeout(8000), + }, ); + // Okta answers an invalid code with 403 (E0000068). + if (verifyRes.status === 403) { + block(res, 9002003, "The Okta Verify code is invalid."); + return; + } if (!verifyRes.ok) { console.error(`Okta verify ${verifyRes.status}: ${await verifyRes.text()}`); - res.status(502).json({ error: "okta_verify_failed" }); + block(res, 9002004, "Okta Verify is unavailable. Please try again."); return; } - let body = (await verifyRes.json()) as OktaVerifyResponse; - const pollUrl = body._links?.poll?.href; - if (!pollUrl) { - res.status(502).json({ error: "missing_poll_url" }); + const body = (await verifyRes.json()) as OktaVerifyResponse; + if (body.factorResult !== "SUCCESS") { + block(res, 9002003, "The Okta Verify code is invalid."); return; } - const deadline = Date.now() + POLL_DEADLINE_MS; - let result: string | undefined = body.factorResult; - - while (Date.now() < deadline && result === "WAITING") { - // 2-second backoff between polls. Okta returns immediately rather than long-polling. - await new Promise((r) => setTimeout(r, 2000)); - const pollRes = await fetch(pollUrl, { - headers: oktaHeaders, - signal: AbortSignal.timeout(10000), - }); - if (!pollRes.ok) { - console.error(`Okta poll ${pollRes.status}: ${await pollRes.text()}`); - res.status(502).json({ error: "okta_poll_failed" }); - return; - } - body = (await pollRes.json()) as OktaVerifyResponse; - result = body.factorResult; - } - - res.json({ - ok: true, - result, // SUCCESS / REJECTED / TIMEOUT / WAITING (if deadline hit) - mfa_passed: result === "SUCCESS", - }); + res.status(200).json({}); } catch (err) { + if (err instanceof UnenrolledError) { + unenrolled(res); + return; + } console.error("Okta error:", (err as Error).message); - res.status(502).json({ error: "okta_failed" }); + block(res, 9002004, "Okta Verify is unavailable. Please try again."); } }, ); diff --git a/mfa/yubikey/README.md b/mfa/yubikey/README.md index 28567e4..c781315 100644 --- a/mfa/yubikey/README.md +++ b/mfa/yubikey/README.md @@ -13,8 +13,8 @@ The full walkthrough lives in the [Ory docs page above](https://www.ory.com/docs 1. In Ory Console → **Authentication** → **Multi-factor**, enable **WebAuthn**. 2. Configure the **Relying Party (RP)**: - - `id`: your auth domain (e.g. `auth.example.com`) — the eTLD+1 the WebAuthn credential is scoped to. - - `origins`: the full origins users sign in from (e.g. `https://app.example.com`). + - `id`: the domain of your login page or a parent domain of it, without scheme or port (e.g. `example.com` or `auth.example.com` for a login page on `auth.example.com`). The WebAuthn credential is scoped to this domain. + - `origins`: the exact origins (scheme, host, and port) of the pages that prompt for WebAuthn (e.g. `https://auth.example.com`). Each origin's host must be the `id` or a subdomain of it. - `display_name`: shown to users in the YubiKey enrollment prompt. 3. (Optional) Enable **Passkeys** as a first factor — same WebAuthn mechanism but used as primary credential rather than second factor. 4. Users can register YubiKeys (or other FIDO2 authenticators — TouchID, Windows Hello, etc.) via the Settings flow. diff --git a/mfa/yubikey/registry.entry.yaml b/mfa/yubikey/registry.entry.yaml index 3a064c0..5f16ada 100644 --- a/mfa/yubikey/registry.entry.yaml +++ b/mfa/yubikey/registry.entry.yaml @@ -18,7 +18,7 @@ useCase: >- are the hardware reference but the same mechanism backs TouchID / Windows Hello / Android biometrics. coreFunctionality: |- - Configured per Ory docs at /kratos/mfa/webauthn-fido-yubikey; Ory Console → Authentication → Multi-factor → enable WebAuthn. - - Relying Party (RP) config: id (eTLD+1 the credential is scoped to), origins (full URLs users sign in from), display_name (shown in enrollment prompt). + - Relying Party (RP) config: id (domain of the login page or a parent domain, no scheme or port), origins (exact origins of the pages that prompt for WebAuthn, each under id), display_name (shown in enrollment prompt). - Covers all FIDO2 authenticators (YubiKey, TouchID, Face ID, Windows Hello, Android biometrics) — "YubiKey" in the doc title is historical. - Phishing-resistant: WebAuthn binds credential to origin, blocking credential theft via look-alike domains. - AAL2 (NIST 800-63) compliance: WebAuthn satisfies AAL2 paired with a password-or-passkey first factor. diff --git a/registry.yaml b/registry.yaml index e95f115..d6e60b3 100644 --- a/registry.yaml +++ b/registry.yaml @@ -8,7 +8,7 @@ apiVersion: ory.com/integrations-v1 kind: Registry metadata: name: ory-integrates - generated: 2026-08-31T14:26:35Z + generated: 2026-10-08T18:28:37Z integrations: - name: skyfire displayName: Skyfire @@ -1116,8 +1116,8 @@ integrations: An Ory Network customer with EU data-residency or GDPR concerns wants a EU-headquartered email vendor for identity emails, in addition to or instead of US-based providers like SendGrid or Mailgun. coreFunctionality: |- - - SMTP relay (recommended): smtps://:@smtp-relay.brevo.com:587 (STARTTLS) or :465 (TLS). - - REST API alternative: POST https://api.brevo.com/v3/smtp/email with header `api-key: ` and a Jsonnet body wired via Kratos's HTTP courier. + - SMTP relay (recommended): smtp://:@smtp-relay.brevo.com:587 (STARTTLS) or smtps://…@smtp-relay.brevo.com:465 (implicit TLS). + - REST API alternative: POST https://api.brevo.com/v3/smtp/email with header `api-key: ` and a Jsonnet body wired via Kratos's HTTP email courier (courier.delivery_strategy: http). - Brevo SMTP keys are distinct from API keys — do not confuse them. - Free tier rate-limits SMTP; identity flows at scale should be on a paid plan. - DKIM and Return-Path must be aligned with the From domain for Gmail/Microsoft deliverability. @@ -1249,8 +1249,8 @@ integrations: An Ory Network customer with high transactional email volume and stringent deliverability requirements wants enterprise-grade tooling and per-region (US vs EU) hosting for data-residency. coreFunctionality: |- - - SMTP relay (recommended): smtps://SMTP_Injection:@smtp.sparkpostmail.com:587 (US) or smtp.eu.sparkpost.com (EU). The literal username "SMTP_Injection" is correct for all customers; password is the API key. - - REST Transmissions API alternative: POST https://api.sparkpost.com/api/v1/transmissions (US) or api.eu.sparkpost.com (EU) with `Authorization: ` and a Jsonnet body. + - SMTP relay (recommended): smtp://SMTP_Injection:@smtp.sparkpostmail.com:587 (US, STARTTLS) or smtp.eu.sparkpostmail.com (EU). The literal username "SMTP_Injection" is correct for all customers; password is the API key. + - REST Transmissions API alternative: POST https://api.sparkpost.com/api/v1/transmissions (US) or api.eu.sparkpost.com (EU) with `Authorization: ` and a Jsonnet body, via Kratos's HTTP email courier (courier.delivery_strategy: http). - SparkPost separates US and EU accounts — pick data residency before integration; migrations are not trivial. - API key needs the "Send via SMTP" permission; minimum scope is sufficient. - Stored templates available via the API if SparkPost-side template management is preferred over Kratos-side rendering (not recommended — Ory renders templates by default). @@ -1777,13 +1777,12 @@ integrations: useCase: >- A B2C product wants to keep credential-stuffing and account-takeover attempts out of its login flow without blocking legitimate users behind a CAPTCHA. Castle scores each login in real time; the integration applies an - allow / challenge / deny decision before the session is issued, with challenge events flagged for MFA step-up in - the client. + allow / challenge / deny decision before the session is issued, and requires a second factor for challenge events. coreFunctionality: |- - - Receives Ory post-login and post-registration Action webhooks. - - Forwards the identity, request context, and Castle device token to Castle's Risk API. - - Translates Castle's response into Ory flow control: 400 to block, 200 + requires_mfa_stepup=true to step up, 200 with risk score in metadata to allow. - - Records the risk assessment in identity metadata for downstream auditing and policy decisions. + - Receives a sync login action (response.parse: true) and a registration action that runs after the identity is saved. + - Forwards the identity ID, client IP (True-Client-Ip), request headers, and Castle request token to Castle's Risk API. + - At login: 403 with a messages body to block, 200 with required_aal aal2 to require a second factor (webhook_response_directives feature flag), 200 to allow. + - At registration: reports the event; on deny, optionally deactivates the identity through the Ory Admin API. - Fails open on Castle outages so authentication never breaks because of Castle availability. oryMechanism: - actions @@ -1880,16 +1879,16 @@ integrations: status: active description: >- Digital trust & safety via Sift — real-time ML-based fraud and account-abuse scoring. Wired into Ory via two - Actions: a sync check on submission (registration.after / login.after with can_interrupt) reading the user's - current Sift abuse score and blocking when above threshold, plus an async post-event reporter emitting Sift events - that feed the model. + Actions: a sync check at sign-in (login.after with response.parse) reading the user's current Sift abuse score and + blocking with a 4xx when above threshold, plus a post-event reporter (response.ignore) emitting Sift events that + feed the model. useCase: >- A consumer product (marketplace, fintech, gaming) needs ML-driven account-abuse prevention beyond bot defense — repeat-fraudster detection, payment-fraud signals, network-effect risk — and is willing to feed Sift the event stream needed to build a useful longitudinal model. coreFunctionality: |- - - Two flows must run for Sift to be useful: pre-flow check (block on fail) AND post-event reporter (signal collection). - - Pre-flow: GET https://api.sift.com/v205/users/{user_id}/score; block if score > threshold (start permissive at 80, tighten with data). + - Two flows must run for Sift to be useful: sign-in check (block on fail) AND post-event reporter (signal collection). + - Sign-in check: GET https://api.sift.com/v205/users/{user_id}/score; block if score > threshold (scores are 0-1 in the API; start permissive at 0.8, tighten with data). - Post-event: POST https://api.sift.com/v205/events with reserved event names ($create_account, $login, $transaction) including IP, user agent, session, traits. - Both flows authenticated via a single server-side API Key. - User IDs must be stable across sessions — Sift's model is keyed on user_id. @@ -2149,17 +2148,17 @@ integrations: description: >- Equifax provides credit-anchored identity verification through its Digital Identity Trust APIs. This integration calls Equifax from an Ory Action webhook during registration to verify identity attributes against credit-bureau - data and returns a decision plus trust score so Ory's post-flow Action can gate the flow. + data and rejects the registration unless Equifax returns a passing decision. useCase: >- A regulated B2C product (fintech, lending, insurance) needs credit-anchored identity proofing on every signup to satisfy KYC requirements. Equifax returns a deterministic decision and confidence score; the integration runs the lookup inline with Ory registration so high-confidence identities pass through and low-confidence ones can be blocked or routed to step-up verification. coreFunctionality: |- - - Sync post-registration Ory Action webhook (response.parse: true, can_interrupt: true). + - Registration action with response.parse: true in the method-specific hook lists, before the identity is saved. - Exchanges Equifax OAuth2 client credentials for an access token, cached and refreshed ~5 min before expiry. - Calls Equifax's identity-verification endpoint with first_name, last_name, date_of_birth, optional ssn_last_four, and address. - - Returns decision/outcome, trust_score, and reference_id to Ory so the post-flow Action can apply policy. + - Passing decisions (EQUIFAX_PASS_DECISIONS) return 200 with the result in metadata_admin and the handler's reference as external_id; any other decision returns 400 with a messages body to reject the registration. - 502 on Equifax errors / outages so the verdict is never silently a pass. oryMechanism: - actions @@ -2176,16 +2175,16 @@ integrations: status: active description: >- Run GBG (which acquired Acuant) identity verification — document + biometric — from an Ory Actions webhook during - registration. Calls the GBG GO Journey API, returns the GBG decision (PASS / REFER / FAIL) so Ory can gate the - registration flow, and writes the verdict to identity metadata for downstream policy. + registration. Calls the GBG GO Journey API, lets PASS through with the verdict in identity metadata, and rejects + FAIL (and, by default, REFER) with a messages response. useCase: >- A regulated product wants document + biometric identity verification with GBG's deep international document coverage (long Acuant heritage), wired into a configurable GBG "journey" so the same Ory action can back several risk levels by swapping the journey id. coreFunctionality: |- - - One sync endpoint POST /gbg/verify-identity called from registration.after with response.parse: true so Ory consumes the GBG decision directly. + - One sync endpoint POST /gbg/verify-identity called from the method-specific registration.after hook lists with response.parse: true, before the identity is saved. - GBG-side flow defined as a "journey" (journey id is required); journey definition controls which evidence types are required (document, selfie, address, etc.). - - Decision values: PASS / REFER / FAIL — the action fails the registration on REFER/FAIL, or writes the verdict into identity.metadata for downstream policy. + - Decision values: PASS / REFER / FAIL — the handler returns 200 with the verdict on PASS and 400 with a messages body on FAIL; REFER is rejected unless GBG_REFER_ACTION=allow. - Authenticated via X-Webhook-Secret shared with the Ory hook config. - First-call cold-path latency can be 5-10s; handler caps at 15s. oryMechanism: @@ -2229,17 +2228,17 @@ integrations: maintainedBy: Ory Engineering status: active description: >- - Run Jumio identity verification (Document + Selfie + Face Match) from Ory Actions during registration; consume + Run Jumio identity verification (Document + Selfie + Face Match) from an Ory Action after registration; consume Jumio's HMAC-signed callback when the workflow finishes and gate login on the stored verification status. Common in regulated industries (financial services, healthcare, gambling, crypto) for high-assurance KYC. useCase: >- A regulated B2C product (fintech, crypto, online gambling, regulated healthcare) needs document + biometric KYC at signup with a vendor that's already on most regulators' approved lists. coreFunctionality: |- - - Three endpoints: POST /jumio/initiate (sync registration.after; starts workflow with customerInternalReference = Kratos identity id); POST /jumio/callback (async from Jumio; HMAC-SHA256 verified; PATCHes verdict to identity metadata); POST /jumio/validate (sync login.after with can_interrupt: true; blocks rejected identities). + - Three endpoints: POST /jumio/initiate (registration.after without response.parse, after the identity is saved; starts workflow with customerInternalReference = Ory identity id); POST /jumio/callback (async from Jumio; HMAC-SHA256 verified; writes the decision to metadata_public); POST /jumio/validate (login.after with response.parse; blocks rejected identities with a 403 and a messages body). - customerInternalReference is the binding between Jumio workflow and Kratos identity — set on initiate, consumed on callback. - - Identity-side state stored in metadata_public via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). - - Configured via Ory Actions on registration.after (sync) + login.after (sync, can_interrupt) — no Kratos schema changes required. + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity). + - Hooks go in the method-specific lists (after.password.hooks, after.oidc.hooks, ...) — no Kratos schema changes required. - SDK: @ory/client for Kratos admin API; native fetch for Jumio API. oryMechanism: - actions @@ -2256,7 +2255,7 @@ integrations: maintainedBy: Ory Engineering status: active description: >- - Run LexisNexis Risk Solutions InstantID identity verification from Ory Actions during registration. Database- + Run LexisNexis Risk Solutions InstantID identity verification from an Ory Action during registration. Database- backed scoring (NAS / NAP / CVI) of name/address/phone/SSN/DOB matches — common in US financial services and insurance where document scans are too high-friction. Gates login on the stored verification status. useCase: >- @@ -2264,11 +2263,11 @@ integrations: without document upload, scoring against authoritative US data — and wants the threshold per check configurable per product risk profile. coreFunctionality: |- - - Two endpoints: POST /lexisnexis/verify (sync registration.after with can_interrupt; calls InstantID, evaluates NAS/NAP/CVI vs thresholds, PATCHes verdict to identity); POST /lexisnexis/validate (sync login.after with can_interrupt; blocks identities with failed status). - - NAS / NAP / CVI scores returned per inquiry — defaults 50 (calibrated for low-risk consumer flows; tune up for higher risk). - - Identity-side state stored in metadata_public via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Two endpoints: POST /lexisnexis/verify (registration.after with response.parse, before the identity is saved; calls InstantID, evaluates NAS/NAP/CVI vs thresholds, returns the verdict in the response or rejects the registration); POST /lexisnexis/validate (login.after with response.parse; blocks identities with failed status). + - NAS and NAP range from 0 to 12, CVI from 0 to 50; thresholds are required env vars agreed with LexisNexis. + - Identity-side state returned in the webhook response as metadata_public, saved by Ory with the new identity. - Synchronous-only — no async callback path (InstantID returns its decision in the API response). - - SDK: @ory/client for Kratos admin API; native fetch for LexisNexis API. + - Native fetch for the LexisNexis API; no Ory Admin API calls. oryMechanism: - actions protocol: @@ -2283,17 +2282,17 @@ integrations: status: active description: >- Run Onfido identity verification (document, biometric, motion, database checks composed via configurable - workflows) from Ory Actions during registration; consume Onfido's HMAC-signed callback when the workflow finishes - and gate login on the stored verification status. + workflows) after registration; consume Onfido's HMAC-signed callback when the workflow finishes and gate login on + the stored verification status. useCase: >- A B2C product needs identity verification at signup with global coverage (especially EU + UK) and the flexibility to compose document, selfie, motion, and database checks per workflow without per-region code. coreFunctionality: |- - - Three endpoints: POST /onfido/initiate (sync registration.after; creates applicant + workflow run with custom_data.kratos_identity_id; returns SDK token); POST /onfido/callback (async from Onfido; X-SHA2-Signature HMAC-SHA256; PATCHes verdict to identity metadata); POST /onfido/validate (sync login.after with can_interrupt: true; blocks declined identities). + - Three endpoints: POST /onfido/initiate (called by the application backend after sign-up; creates applicant + workflow run with custom_data.kratos_identity_id; returns SDK token); POST /onfido/callback (async from Onfido; X-SHA2-Signature HMAC-SHA256; writes the status to metadata_public); POST /onfido/validate (login.after with response.parse; blocks declined identities with a 403 and a messages body). - custom_data.kratos_identity_id is the binding between Onfido workflow run and Kratos identity — set on initiate, consumed on callback. - Region-aware (ONFIDO_REGION = EU/US/CA) — selects the right API base host for data residency. - Onfido SDK token returned by initiate is short-lived; client must render the verification UI immediately. - - Identity-side state stored in metadata_public via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity). oryMechanism: - actions protocol: @@ -2309,17 +2308,17 @@ integrations: status: active description: >- Run Persona identity verification (configurable templates — document capture, selfie + liveness, database checks, - watchlist screening) from Ory Actions during registration; consume Persona's HMAC-signed callback when the inquiry - completes and gate login on the stored verification status. + watchlist screening) after registration; consume Persona's HMAC-signed callback when the inquiry changes status + and gate login on the stored verification status. useCase: >- A regulated B2C product needs identity verification at signup tailored to product risk — Persona's templates let the same handler back KYC-light onboarding (document + selfie) and high-trust flows (with database verification + watchlist) without changing code. coreFunctionality: |- - - Three endpoints: POST /persona/initiate (sync registration.after; creates Inquiry with reference-id = Kratos identity id; returns hosted URL); POST /persona/callback (async from Persona; HMAC-SHA256 via X-Persona-Signature; PATCHes verdict to identity metadata); POST /persona/validate (sync login.after with can_interrupt: true; blocks login when status == declined). - - Identity-side state stored in metadata_admin via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Three endpoints: POST /persona/initiate (called by the application backend after sign-up, or registration.after without response.parse; creates Inquiry with reference-id = Ory identity id; returns hosted URL); POST /persona/callback (async from Persona; Persona-Signature t=,v1= HMAC-SHA256 over "."; writes the status to metadata_public); POST /persona/validate (login.after with response.parse; blocks login when status == declined). + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity), because the webhook payload has no metadata_admin. - Reference-id is the binding between Persona Inquiry and Kratos identity — set on initiate, consumed on callback. - - Configured via Ory Actions on registration.after (sync) + login.after (sync, can_interrupt) — no Kratos schema changes required. + - Hooks go in the method-specific lists (after.password.hooks, after.oidc.hooks, ...) — no Kratos schema changes required. - SDK: @ory/client for Kratos admin API; native fetch for Persona API. oryMechanism: - actions @@ -2335,18 +2334,16 @@ integrations: maintainedBy: Ory Engineering status: active description: >- - Phone-anchored identity verification — telecom signals, SIM-swap detection, identity prefill, and continuous - phone-ownership verification — across three Ory Actions lifecycle points: pre-registration lookup (prefill), - post-registration enrollment, and per-login validation. + Phone-anchored identity verification — telecom signals and continuous phone-ownership verification — at two Ory + Actions lifecycle points: post-registration enrollment and per-login validation. useCase: >- - A US-focused product wants signup friction reduced (Prove prefill cuts forms by autofilling name/address from the - phone number) AND wants ongoing phone-ownership verification so a SIM-swapped phone can't be used to resume an - existing account. + A US-focused product wants ongoing phone-ownership verification so a changed or SIM-swapped phone can't be used to + resume an existing account. coreFunctionality: |- - - Three sync endpoints: POST /prove/lookup (registration.before with can_interrupt — prefill or block on SIM swap), POST /prove/enroll (registration.after — enroll phone with Prove Identity Manager, store prove_identity_id), POST /prove/validate (login.after with can_interrupt — confirm continuous phone ownership, block on a fail). - - All sync — Prove's APIs return decisions in the response, no async callbacks. - - Phone collection is a prerequisite — identity schema must capture E.164 phone number before /prove/lookup fires. - - Identity-side state stored in metadata_admin via @ory/client (IdentityApi.patchIdentity with JsonPatchOpEnum). + - Two endpoints: POST /prove/enroll (registration.after without response.parse, after the identity is saved — enroll phone with Prove Identity Manager, store prove_identity_id in metadata_public), POST /prove/validate (login.after with response.parse — confirm the phone on file still matches, block on a mismatch). + - Prove's APIs return decisions in the response, no async callbacks. Prefill isn't covered: a registration.before action has no phone number or traits. + - Phone collection is a prerequisite — identity schema must capture an E.164 phone number. + - Identity-side state stored in metadata_public via @ory/client (IdentityApi.getIdentity + patchIdentity), because the webhook payload has no metadata_admin. - Uses Prove's official SDK (@prove-identity/prove-api) for the API surface. - Defaults to UAT (platform.uat.proveapis.com); override PROVE_SERVER_URL for production. oryMechanism: @@ -2372,8 +2369,8 @@ integrations: checks — device, email, network, phone risk — and wants a single deployment that handles both fast and slow Socure verdict paths. coreFunctionality: |- - - Sync path: POST /socure/verify-identity (Ory Action target on registration.after); calls Socure ID+ POST /api/3.0/EmailAuthScore with configured SOCURE_MODULES; returns reference_id / decision (kyc.fieldValidations) / fraud_score for the response-parse Jsonnet to apply policy. - - Async path: POST /socure/results-callback (FROM Socure when async checks complete); HMAC-SHA256 over raw body via X-Socure-Signature; when ORY_SDK_URL + ORY_ADMIN_API_KEY are set, PATCHes the final decision to metadata_public.socure_decision on the matching identity. + - Sync path: POST /socure/verify-identity (registration action with response.parse: true); calls Socure ID+ POST /api/3.0/EmailAuthScore with configured SOCURE_MODULES and the handler's own reference as customerUserId; rejects the registration with a messages response when the decision is reject or the fraud score reaches SOCURE_FRAUD_THRESHOLD, otherwise returns the reference as external_id. + - Async path: POST /socure/results-callback (FROM Socure when async checks complete); HMAC-SHA256 over raw body via X-Socure-Signature; when ORY_SDK_URL + ORY_ADMIN_API_KEY are set, finds the identity by external_id and writes the final decision to metadata_public.socure_decision. - SOCURE_MODULES configures which signals run (commonly kyc, fraud, phonerisk). - Single deployment, single binary handles both paths — async config is optional. - Authenticated via shared ORY_WEBHOOK_SECRET (sync) and SOCURE_CALLBACK_SECRET (async HMAC). @@ -2391,19 +2388,17 @@ integrations: maintainedBy: Community contributors status: active description: >- - Duo Security (a Cisco company) is an enterprise MFA platform with strong push and passcode flows. This integration - sends a Duo push challenge from an Ory Action webhook on login, polls Duo for the user's response, and returns the - verdict so Ory can step up authentication or fail the flow. + Duo Security (a Cisco company) is an enterprise MFA platform with push and passcode flows. This integration + verifies a Duo passcode from an Ory Action webhook at sign-in and blocks the sign-in unless Duo allows it. useCase: >- - An enterprise has already standardized its workforce on Duo Mobile for MFA and wants to reuse that enrollment from - Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. Duo's push challenge runs inline - with Ory login; the integration relays the verdict so Ory can issue the session or fail the flow. + An enterprise has already standardized its workforce on Duo for MFA and wants to reuse that enrollment from + Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. coreFunctionality: |- - - Sync post-login Ory Action webhook (response.parse: true, can_interrupt: true). + - Sync post-login Ory Action webhook in the method-specific login hook lists (response.parse: true). + - Reads the Duo passcode from the flow's transient_payload and the client IP from True-Client-Ip. - Signs Duo Auth API requests with HMAC-SHA1 over a canonicalized request line per Duo's spec. - - POSTs /auth/v2/auth (factor=push, async=1) and long-polls /auth/v2/auth_status?txid=... until the user responds or DUO_POLL_DEADLINE_SECONDS elapses. - - Returns { result: allow | deny | waiting, mfa_passed } so the post-flow Action can gate the session. - - 502 on Duo errors so a Duo outage never silently grants MFA. + - POSTs /auth/v2/auth with factor=passcode; Duo answers immediately with allow or deny. + - Responds 200 on allow and 403 with Ory UI messages otherwise, so Duo errors never grant access and Ory never retries. oryMechanism: - actions protocol: @@ -2417,20 +2412,17 @@ integrations: maintainedBy: Community contributors status: active description: >- - Okta Verify is Okta's first-party MFA app supporting push notifications and TOTP. This integration sends an Okta - Verify push from an Ory Action webhook on login, polls Okta for the user's response, and returns the verdict so - Ory can step up authentication or fail the flow. + Okta Verify is Okta's first-party MFA app supporting push notifications and one-time codes. This integration + verifies an Okta Verify code from an Ory Action webhook at sign-in and blocks the sign-in unless Okta accepts it. useCase: >- - An enterprise has already standardized its workforce on Okta Verify for MFA and wants to reuse that factor - enrollment from Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. Okta Verify push - runs inline with Ory login and relays the verdict so Ory issues the session only on approval. + An enterprise has already standardized its workforce on Okta Verify and wants to reuse that enrollment from + Ory-hosted login flows without re-enrolling users in Ory's native TOTP/WebAuthn. coreFunctionality: |- - - Sync post-login Ory Action webhook (response.parse: true, can_interrupt: true). - - GET /api/v1/users/{user_id}/factors → finds the active push factor. - - POST /api/v1/users/{user_id}/factors/{factor_id}/verify → sends the push, gets a poll URL. - - Polls the poll URL every 2 s until factorResult ≠ WAITING or OKTA_POLL_DEADLINE_SECONDS elapses. - - Returns { result: SUCCESS / REJECTED / TIMEOUT / WAITING, mfa_passed } so the post-flow Action can gate the session. - - 502 on Okta errors (SSWS revoked, factor lookup 404) so Okta outages never silently grant MFA. + - Sync post-login Ory Action webhook in the method-specific login hook lists (response.parse: true). + - Reads the Okta user ID from metadata_public.okta_user_id and the code from the flow's transient_payload. + - GET /api/v1/users/{userId}/factors → finds the active Okta Verify code factor (token:software:totp). + - POST /api/v1/users/{userId}/factors/{factorId}/verify with passCode; Okta answers immediately. + - Responds 200 on SUCCESS and 403 with Ory UI messages otherwise; unenrolled users are blocked unless OKTA_ALLOW_UNENROLLED=true. oryMechanism: - actions protocol: @@ -2454,7 +2446,7 @@ integrations: reference but the same mechanism backs TouchID / Windows Hello / Android biometrics. coreFunctionality: |- - Configured per Ory docs at /kratos/mfa/webauthn-fido-yubikey; Ory Console → Authentication → Multi-factor → enable WebAuthn. - - Relying Party (RP) config: id (eTLD+1 the credential is scoped to), origins (full URLs users sign in from), display_name (shown in enrollment prompt). + - Relying Party (RP) config: id (domain of the login page or a parent domain, no scheme or port), origins (exact origins of the pages that prompt for WebAuthn, each under id), display_name (shown in enrollment prompt). - Covers all FIDO2 authenticators (YubiKey, TouchID, Face ID, Windows Hello, Android biometrics) — "YubiKey" in the doc title is historical. - Phishing-resistant: WebAuthn binds credential to origin, blocking credential theft via look-alike domains. - AAL2 (NIST 800-63) compliance: WebAuthn satisfies AAL2 paired with a password-or-passkey first factor. @@ -2827,8 +2819,8 @@ integrations: coreFunctionality: |- - Configured via Kratos's HTTP SMS courier (no code) per docs/kratos/emails-sms/sending-sms (the example uses Twilio directly). - Endpoint POST https://api.twilio.com/2010-04-01/Accounts//Messages.json with HTTP Basic auth (Account SID : Auth Token). - - Content-Type MUST be application/x-www-form-urlencoded (Twilio rejects JSON); body is URL-encoded To, From, Body. - - For production, prefer a Twilio Messaging Service (replace From=... with MessagingServiceSid=MGxxxx...) for sender pooling and per-country routing. + - Content-Type MUST be application/x-www-form-urlencoded (Twilio rejects JSON); the Jsonnet template returns an object of string fields To, From, Body, which the courier form-encodes. + - For production, prefer a Twilio Messaging Service (replace From with MessagingServiceSid) for sender pooling and per-country routing. - Twilio Test Credentials (separate SID/Token) simulate sends without delivery or charges — useful for CI. oryMechanism: - courier-spi @@ -2851,9 +2843,9 @@ integrations: regions, in addition to or instead of US-centric Twilio. coreFunctionality: |- - Configured via Kratos's HTTP SMS courier (no code) per docs/kratos/emails-sms/sending-sms. - - Endpoint POST https://rest.nexmo.com/sms/json. Authentication is API key + secret in the request BODY (not headers), so the Kratos courier `auth` block stays empty and credentials live in the Jsonnet template. - - JSON body: api_key, api_secret, from (alphanumeric ID up to 11 chars OR a Vonage virtual number — alphanumeric not allowed in US/Canada), to, text. - - Vonage accepts E.164 with or without leading "+"; strip in Jsonnet if a country rejects the + form. + - Endpoint POST https://rest.nexmo.com/sms/json with HTTP Basic auth (API key : API secret) through the courier's basic_auth block. + - Content-Type application/x-www-form-urlencoded; form fields from (alphanumeric ID up to 11 chars OR a Vonage virtual number — alphanumeric not allowed in US/Canada), to, text. + - The Jsonnet template strips the leading "+" from Ory's E.164 recipient. - Newer Messages API (api.nexmo.com/v1/messages) available with JWT/header auth; the REST SMS API above is simpler for SMS-only use. oryMechanism: - courier-spi @@ -2879,7 +2871,7 @@ integrations: API as an authentication-template message. coreFunctionality: |- - Kratos courier-spi HTTP target (configured via kratos-courier.yaml, not ory-actions.yaml). - - Extracts the OTP from the rendered Kratos body via strict regex /\b(?:code|pin|otp)[^\d]{0,16}(\d{4,10})\b/i. + - Takes the OTP from template_data (login_code, registration_code, or verification_code) via the body template, falling back to extracting it from the rendered Kratos body via strict regex /\b(?:code|pin|otp)[^\d]{0,16}(\d{4,10})\b/i. - Normalizes recipient to E.164 (leading "+" then digits). - POSTs to graph.facebook.com/{version}/{phone_id}/messages with type: template, sending the OTP as both the body parameter and the copy-code button parameter. - 422 otp_extract_failed when the message body doesn't yield a digit run — explicit instead of silently sending an empty template. diff --git a/scripts/test-verification-integrations.cjs b/scripts/test-verification-integrations.cjs new file mode 100644 index 0000000..24403dc --- /dev/null +++ b/scripts/test-verification-integrations.cjs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 +// Run after npm ci in each handler under test: node --test scripts/test-verification-integrations.cjs +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { + loadWebhook, + response, + json, +} = require("./testing/webhook-fixture.cjs"); +const file = (name) => + path.resolve(__dirname, `../identity-verification/${name}/webhook/server.ts`); +for (const [phone, expected] of [ + ["+15550000001", 200], + ["+15550000002", 403], +]) { + test(`Prove checks the SDK phone number: ${expected}`, async () => { + const routes = loadWebhook(file("prove"), { + prove: { + async v3GetIdentity() { + return { + httpMeta: {}, + v3GetIdentityResponse: { + active: true, + phoneNumber: "+15550000001", + }, + }; + }, + }, + }); + const res = response(); + await routes.get("/prove/validate")( + { body: { prove_identity_id: "prove-id", phone_number: phone } }, + res, + ); + assert.equal(res.code, expected); + if (expected === 403) + assert.match(res.body.messages[0].messages[0].text, /phone number/); + }); +} +test("Persona builds a usable hosted URL from the documented inquiry response", async () => { + let request; + const routes = loadWebhook(file("persona"), { + fetch: async (_url, init) => { + request = JSON.parse(init.body); + return json({ + data: { id: "inq_fixture", attributes: { status: "created" } }, + }); + }, + }); + const res = response(); + await routes.get("/persona/initiate")( + { body: { kratos_identity_id: "identity-id", email: "a@example.com" } }, + res, + ); + assert.equal(res.code, 200); + assert.equal( + res.body.verification_url, + "https://inquiry.withpersona.com/verify?inquiry-id=inq_fixture", + ); + assert.equal( + request.data.attributes.fields["email-address"], + "a@example.com", + ); + assert.equal(request.data.attributes["inquiry-type"], undefined); +}); +test("Persona metadata patches preserve an unrelated update made after the GET", async () => { + let metadata = { existing: true }; + const routes = loadWebhook(file("persona"), { + fetch: async () => + json({ data: { id: "inq_fixture", attributes: { status: "created" } } }), + sdk: { + IdentityApi: class { + async getIdentity() { + const snapshot = { ...metadata }; + metadata.stripe = { customer_id: "cus_fixture" }; + return { data: { metadata_public: snapshot, metadata_admin: {} } }; + } + async patchIdentity({ jsonPatch }) { + for (const op of jsonPatch) { + assert.notEqual(op.path, "/metadata_public"); + if (op.path.startsWith("/metadata_public/")) + metadata[op.path.split("/")[2]] = op.value; + } + } + }, + }, + }); + await routes.get("/persona/initiate")( + { body: { kratos_identity_id: "identity-id", email: "a@example.com" } }, + response(), + ); + assert.equal(metadata.stripe.customer_id, "cus_fixture"); + assert.equal(metadata.persona_inquiry_id, "inq_fixture"); +}); +test("Metadata initialization retries a stale updated_at test and preserves existing keys", async () => { + let reads = 0, + patches = 0; + const routes = loadWebhook(file("persona"), { + fetch: async () => + json({ data: { id: "inq_fixture", attributes: { status: "created" } } }), + sdk: { + IdentityApi: class { + async getIdentity() { + reads++; + return { + data: { + updated_at: "2026-01-01T00:00:00Z", + metadata_public: + reads === 1 ? null : { stripe: { customer_id: "cus_fixture" } }, + metadata_admin: {}, + }, + }; + } + async patchIdentity({ jsonPatch }) { + patches++; + if (patches === 1) { + assert.equal(jsonPatch[0].op, "test"); + throw { + response: { + status: 400, + data: { reason: "failed to apply JSON patch" }, + }, + }; + } + assert.ok(jsonPatch.every((op) => op.path !== "/metadata_public")); + } + }, + }, + }); + const res = response(); + await routes.get("/persona/initiate")( + { body: { kratos_identity_id: "identity-id", email: "a@example.com" } }, + res, + ); + assert.equal(res.code, 200); + assert.equal(reads, 2); + assert.equal(patches, 2); +}); +test("Socure retries callbacks that arrive before the external ID is persisted", async () => { + let saved = false, + patches = 0; + const routes = loadWebhook(file("socure"), { + env: { + SOCURE_API_KEY: "fixture", + SOCURE_CALLBACK_SECRET: "fixture", + ORY_SDK_URL: "https://ory.test", + ORY_ADMIN_API_KEY: "fixture", + }, + fetch: async (_url, init) => { + if (init.method === "PATCH") { + patches++; + return json({}); + } + return saved ? json({ id: "identity-id" }) : json({}, 404); + }, + }); + const rawBody = Buffer.from( + JSON.stringify({ customerUserId: "external-id", decision: "reject" }), + ); + const req = { + body: JSON.parse(rawBody), + rawBody, + header: () => + crypto.createHmac("sha256", "fixture").update(rawBody).digest("hex"), + }; + const early = response(); + await routes.get("/socure/results-callback")(req, early); + assert.equal(early.code, 500); + assert.equal(patches, 0); + saved = true; + const retry = response(); + await routes.get("/socure/results-callback")(req, retry); + assert.equal(retry.code, 200); + assert.equal(patches, 1); +}); +test("reCAPTCHA denies missing tokens with detailed Kratos messages", async () => { + const routes = loadWebhook( + path.resolve( + __dirname, + "../fraud-bot-protection/recaptcha/webhook/server.ts", + ), + { env: { RECAPTCHA_SECRET_KEY: "fixture" } }, + ); + const res = response(); + await routes.get("/recaptcha/verify")( + { body: { flow: { transient_payload: null } } }, + res, + ); + assert.equal(res.code, 400); + assert.equal(res.body.messages[0].instance_ptr, "#/"); + assert.equal(res.body.messages[0].messages[0].type, "error"); +}); diff --git a/scripts/testing/webhook-fixture.cjs b/scripts/testing/webhook-fixture.cjs new file mode 100644 index 0000000..bf26d9b --- /dev/null +++ b/scripts/testing/webhook-fixture.cjs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// Execute the actual handlers with isolated vendor/Admin API fixtures. +const fs = require("node:fs"); +const vm = require("node:vm"); +const { createRequire } = require("node:module"); +function loadWebhook(file, options = {}) { + const localRequire = createRequire(file); + const ts = localRequire("typescript"); + const routes = new Map(); + const app = { + use() {}, + get() {}, + listen() {}, + post(path, ...handlers) { + routes.set(path, handlers.at(-1)); + }, + }; + const express = Object.assign(() => app, { + json: () => () => {}, + raw: () => () => {}, + }); + const sdk = { + Configuration: class {}, + IdentityApi: class { + async getIdentity() { + return { + data: { + metadata_public: {}, + metadata_admin: {}, + updated_at: "2026-01-01T00:00:00Z", + }, + }; + } + async patchIdentity(data) { + options.patches?.push(data); + } + }, + JsonPatchOpEnum: { Add: "add", Test: "test" }, + ...options.sdk, + }; + const env = { + ORY_WEBHOOK_SECRET: "fixture", + KRATOS_ADMIN_URL: "https://ory.test", + ORY_API_KEY: "fixture", + PROVE_CLIENT_ID: "fixture", + PROVE_CLIENT_SECRET: "fixture", + PERSONA_API_KEY: "fixture", + PERSONA_TEMPLATE_ID: "itmpl_fixture", + PERSONA_WEBHOOK_SECRET: "fixture", + ...options.env, + }; + const context = { + require(name) { + if (name === "express") return express; + if (name === "dotenv/config") return {}; + if (name === "@ory/client") return sdk; + if (name === "@prove-identity/prove-api") + return { + Proveapi: class { + identity = options.prove; + }, + }; + return localRequire(name); + }, + exports: {}, + process: { + env, + exit() { + throw new Error("Unexpected process.exit"); + }, + }, + console: { log() {}, warn() {}, error() {} }, + Buffer, + URL, + URLSearchParams, + AbortSignal, + fetch: + options.fetch ?? + (() => { + throw new Error("Unexpected network call"); + }), + }; + const source = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + esModuleInterop: true, + }, + }).outputText; + vm.runInNewContext(source, context, { filename: file }); + return routes; +} +function response() { + return { + code: 200, + status(code) { + this.code = code; + return this; + }, + json(body) { + this.body = body; + return this; + }, + end() { + return this; + }, + }; +} +function json(body, status = 200) { + return Response.json(body, { status }); +} +module.exports = { loadWebhook, response, json }; diff --git a/sms-providers/messagebird/README.md b/sms-providers/messagebird/README.md index a714a4f..1363a12 100644 --- a/sms-providers/messagebird/README.md +++ b/sms-providers/messagebird/README.md @@ -20,19 +20,17 @@ The Ory docs page covers the SMS courier configuration in general. MessageBird-s 1. Sign up at [messagebird.com](https://www.messagebird.com); generate a live API key under **Developer → API access**. 2. Pick an **originator** — a MessageBird virtual number, or an alphanumeric sender ID (up to 11 chars; not supported in US/Canada/China). -3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) using the values in the table above. The Jsonnet body strips the leading `+` from Ory's E.164 recipient before passing it to MessageBird (which expects MSISDN format). +3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) using the values in the table above. The Jsonnet template returns the request body object directly, and strips the leading `+` from Ory's E.164 recipient because MessageBird expects MSISDN format. Encode it and set it as `body: base64://`. ```jsonnet function(ctx) { - body: std.manifestJsonEx({ - originator: "YourApp", - recipients: [ - if std.startsWith(ctx.recipient, "+") - then std.substr(ctx.recipient, 1, std.length(ctx.recipient) - 1) - else ctx.recipient, - ], - body: ctx.body, - }, " "), + originator: 'YourApp', + recipients: [ + if std.startsWith(ctx.recipient, '+') + then std.substr(ctx.recipient, 1, std.length(ctx.recipient) - 1) + else ctx.recipient, + ], + body: ctx.body, } ``` diff --git a/sms-providers/plivo/README.md b/sms-providers/plivo/README.md index 69d6c63..a4845f7 100644 --- a/sms-providers/plivo/README.md +++ b/sms-providers/plivo/README.md @@ -20,15 +20,13 @@ The Ory docs page covers the SMS courier configuration in general. Plivo-specifi 1. Sign up at [plivo.com](https://www.plivo.com); grab the **Auth ID** and **Auth Token** from the console. 2. Buy an SMS-enabled phone number — this is your `src`. -3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) using the values in the table above. Plivo accepts E.164 directly (no `+` stripping needed). +3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) using the values in the table above. Plivo accepts E.164 directly (no `+` stripping needed). Set the `basic_auth` `user` and `password` to the plain Auth ID and Auth Token strings, and set the Jsonnet template below, encoded, as `body: base64://`. The template returns the request body object directly. ```jsonnet function(ctx) { - body: std.manifestJsonEx({ - src: "+1XXXXXXXXXX", - dst: ctx.recipient, - text: ctx.body, - }, " "), + src: '+1XXXXXXXXXX', + dst: ctx.recipient, + text: ctx.body, } ``` diff --git a/sms-providers/sinch/README.md b/sms-providers/sinch/README.md index 20fbd1b..7e5f6c0 100644 --- a/sms-providers/sinch/README.md +++ b/sms-providers/sinch/README.md @@ -47,7 +47,7 @@ ory patch identity-config \ "authorization": "Bearer ", "content-type": "application/json" }, - "body": "base64://'"$(base64 < sinch-body.jsonnet)"'" + "body": "base64://'"$(base64 < sinch-body.jsonnet | tr -d '\n')"'" } }]' ``` diff --git a/sms-providers/twilio/README.md b/sms-providers/twilio/README.md index 74a2d57..fb7a5a0 100644 --- a/sms-providers/twilio/README.md +++ b/sms-providers/twilio/README.md @@ -21,17 +21,17 @@ The Ory docs page walks through the SMS courier configuration end-to-end using T 1. Sign up at [twilio.com](https://www.twilio.com); copy the **Account SID** and **Auth Token** from the console. 2. Buy an SMS-enabled Twilio phone number — this is your `From`. -3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) (the example uses Twilio directly). +3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) (the example uses Twilio directly). Set the `basic_auth` `user` and `password` to the plain Account SID and Auth Token strings, and the `Content-Type` header to `application/x-www-form-urlencoded`. With that content type, the courier turns the object the template returns into form fields, so every value must be a string. Encode the template and set it as `body: base64://`. ```jsonnet function(ctx) { - body: "To=" + std.encodeURI(ctx.recipient) - + "&From=" + std.encodeURI("+1XXXXXXXXXX") - + "&Body=" + std.encodeURI(ctx.body), + To: ctx.recipient, + From: '+1XXXXXXXXXX', + Body: ctx.body, } ``` -For production, consider a Twilio **Messaging Service** (sender pool, country-specific routing, compliance) — replace the `From=...` parameter with `MessagingServiceSid=MGxxxx...`. +For production, consider a Twilio **Messaging Service** (sender pool, country-specific routing, compliance) — replace the `From` field with `MessagingServiceSid: 'MGxxxx...'`. ## Notes diff --git a/sms-providers/twilio/registry.entry.yaml b/sms-providers/twilio/registry.entry.yaml index 60e78e4..78b1322 100644 --- a/sms-providers/twilio/registry.entry.yaml +++ b/sms-providers/twilio/registry.entry.yaml @@ -16,8 +16,8 @@ useCase: >- coreFunctionality: |- - Configured via Kratos's HTTP SMS courier (no code) per docs/kratos/emails-sms/sending-sms (the example uses Twilio directly). - Endpoint POST https://api.twilio.com/2010-04-01/Accounts//Messages.json with HTTP Basic auth (Account SID : Auth Token). - - Content-Type MUST be application/x-www-form-urlencoded (Twilio rejects JSON); body is URL-encoded To, From, Body. - - For production, prefer a Twilio Messaging Service (replace From=... with MessagingServiceSid=MGxxxx...) for sender pooling and per-country routing. + - Content-Type MUST be application/x-www-form-urlencoded (Twilio rejects JSON); the Jsonnet template returns an object of string fields To, From, Body, which the courier form-encodes. + - For production, prefer a Twilio Messaging Service (replace From with MessagingServiceSid) for sender pooling and per-country routing. - Twilio Test Credentials (separate SID/Token) simulate sends without delivery or charges — useful for CI. oryMechanism: - courier-spi diff --git a/sms-providers/vonage/README.md b/sms-providers/vonage/README.md index f4404dd..b2de252 100644 --- a/sms-providers/vonage/README.md +++ b/sms-providers/vonage/README.md @@ -13,30 +13,30 @@ The Ory docs page covers the SMS courier configuration in general. Vonage-specif | --- | --- | | URL | `https://rest.nexmo.com/sms/json` | | Method | `POST` | -| Auth | API key + secret in the **request body** (not headers) | -| Body | JSON with `api_key`, `api_secret`, `from`, `to`, `text` | +| Content-Type | `application/x-www-form-urlencoded` | +| Auth | HTTP Basic (`API key` : `API secret`) | +| Body | Form fields `from`, `to` (no leading `+`), `text` | -> Vonage's REST SMS API authenticates via credentials in the body, so the Kratos courier `auth` block stays empty — credentials live in the Jsonnet template instead. +> Use the courier's `basic_auth` block with the API key as `user` and the API secret as `password`, both as plain strings. This keeps the credentials out of the Jsonnet template. ## Setup 1. Sign up at [vonage.com](https://www.vonage.com); copy the **API key** and **API secret** from the dashboard. 2. Pick a sender — a Vonage virtual number, or an alphanumeric ID (up to 11 chars; not allowed in US/Canada). -3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) using the values in the table above. +3. Configure the SMS courier following the [Ory docs page](https://www.ory.com/docs/kratos/emails-sms/sending-sms) using the values in the table above. With `application/x-www-form-urlencoded`, the courier turns the object the template returns into form fields, so every value must be a string. Encode the template and set it as `body: base64://`. ```jsonnet function(ctx) { - body: std.manifestJsonEx({ - api_key: "YOUR_API_KEY", - api_secret: "YOUR_API_SECRET", - from: "YourApp", - to: ctx.recipient, - text: ctx.body, - }, " "), + from: 'YourApp', + to: + if std.startsWith(ctx.recipient, '+') + then std.substr(ctx.recipient, 1, std.length(ctx.recipient) - 1) + else ctx.recipient, + text: ctx.body, } ``` -Vonage accepts E.164 with or without the leading `+`; if delivery fails on a country, strip the `+` in the Jsonnet template. +Vonage expects the recipient in E.164 format without the leading `+`, so the template strips it. ## License diff --git a/sms-providers/vonage/registry.entry.yaml b/sms-providers/vonage/registry.entry.yaml index 899d4c1..a0134af 100644 --- a/sms-providers/vonage/registry.entry.yaml +++ b/sms-providers/vonage/registry.entry.yaml @@ -16,9 +16,9 @@ useCase: >- regions, in addition to or instead of US-centric Twilio. coreFunctionality: |- - Configured via Kratos's HTTP SMS courier (no code) per docs/kratos/emails-sms/sending-sms. - - Endpoint POST https://rest.nexmo.com/sms/json. Authentication is API key + secret in the request BODY (not headers), so the Kratos courier `auth` block stays empty and credentials live in the Jsonnet template. - - JSON body: api_key, api_secret, from (alphanumeric ID up to 11 chars OR a Vonage virtual number — alphanumeric not allowed in US/Canada), to, text. - - Vonage accepts E.164 with or without leading "+"; strip in Jsonnet if a country rejects the + form. + - Endpoint POST https://rest.nexmo.com/sms/json with HTTP Basic auth (API key : API secret) through the courier's basic_auth block. + - Content-Type application/x-www-form-urlencoded; form fields from (alphanumeric ID up to 11 chars OR a Vonage virtual number — alphanumeric not allowed in US/Canada), to, text. + - The Jsonnet template strips the leading "+" from Ory's E.164 recipient. - Newer Messages API (api.nexmo.com/v1/messages) available with JWT/header auth; the REST SMS API above is simpler for SMS-only use. oryMechanism: - courier-spi diff --git a/sms-providers/whatsapp/README.md b/sms-providers/whatsapp/README.md index ec36a1e..67e0e74 100644 --- a/sms-providers/whatsapp/README.md +++ b/sms-providers/whatsapp/README.md @@ -19,7 +19,7 @@ A consumer product wants WhatsApp as the OTP channel for verification, recovery, 1. A user enters their phone number on a flow that uses OTP delivery (verification, recovery, or MFA via SMS code). 2. Ory Kratos generates an OTP and POSTs the rendered message to this handler at `/whatsapp/send` (Kratos courier HTTP target). -3. The handler verifies the shared secret, extracts the OTP from the message body using a strict regex (`/\b(?:code|pin|otp)[^\d]{0,16}(\d{4,10})\b/i`), and normalizes the recipient to E.164. +3. The handler verifies the shared secret, takes the OTP from the `otp` field that the body template reads from `template_data` (falling back to a strict regex over the rendered message, `/\b(?:code|pin|otp)[^\d]{0,16}(\d{4,10})\b/i`), and normalizes the recipient to E.164. 4. The handler POSTs to `https://graph.facebook.com/${GRAPH_VERSION}/${PHONE_NUMBER_ID}/messages` with a `type: template` payload, passing the OTP as both the body parameter and the copy-code button parameter. 5. WhatsApp delivers the templated message. The handler returns `200` (with the WhatsApp message id) to Kratos so Kratos marks the delivery as sent. @@ -61,7 +61,7 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Kratos -This is a **courier**, not an Ory Action. Use the snippet in [`kratos-courier.yaml`](kratos-courier.yaml) (not `ory-actions.yaml`) under `courier.channels` in your Kratos config. The body template is [`jsonnet/sms-body.jsonnet`](jsonnet/sms-body.jsonnet). +This is a **courier**, not an Ory Action. Use the snippet in [`kratos-courier.yaml`](kratos-courier.yaml) (not `ory-actions.yaml`) as the `sms` channel under `courier.channels` in your Kratos config. The body template is [`jsonnet/sms-body.jsonnet`](jsonnet/sms-body.jsonnet). On Ory Network, `file://` paths aren't available: encode the template with `base64 < jsonnet/sms-body.jsonnet | tr -d '\n'` and set `body: base64://`. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `x-webhook-secret` value declared in the Kratos courier config. @@ -71,7 +71,7 @@ WhatsApp Business pricing, template-approval gotchas, and try-WA / fallback-SMS - **`401 invalid webhook secret`** — `ORY_WEBHOOK_SECRET` doesn't match `x-webhook-secret` in the Kratos courier config. - **`401 Unauthorized` from Graph** — access token expired or insufficiently scoped. Use a system-user permanent token with WhatsApp messaging permission. -- **`422 otp_extract_failed`** — the regex didn't find an OTP in the Kratos body. Either change the Kratos verification template, or relax the regex in `extractOtp()` to match your template's wording. +- **`422 otp_extract_failed`** — the request had no `otp` field and the regex didn't find an OTP in the Kratos body. Use the body template from this directory, change the Kratos SMS template, or relax the regex in `extractOtp()` to match your template's wording. - **`Template name does not exist`** — template name / language code mismatch. The Meta Business Manager values are case-sensitive. - **`recipient not on WhatsApp`** — the number isn't on WhatsApp. There's no automatic SMS fallback; if your audience may not be on WhatsApp, layer a try-WA / fallback-SMS pattern in your courier config or in this handler. diff --git a/sms-providers/whatsapp/jsonnet/sms-body.jsonnet b/sms-providers/whatsapp/jsonnet/sms-body.jsonnet index f630902..686a042 100644 --- a/sms-providers/whatsapp/jsonnet/sms-body.jsonnet +++ b/sms-providers/whatsapp/jsonnet/sms-body.jsonnet @@ -1,10 +1,20 @@ // Body template Kratos passes to the WhatsApp courier handler. // -// Kratos's HTTP courier renders this jsonnet against its message context (recipient, -// rendered body text, message_type, etc.). The handler at /whatsapp/send extracts the -// OTP from `body` and maps it into the WhatsApp Cloud API template payload. -function(ctx) { - recipient: ctx.recipient, - body: ctx.body, - message_type: if std.objectHas(ctx, "message_type") then ctx.message_type else "verification_code", -} +// The code is in template_data.login_code, registration_code, or +// verification_code depending on the template; the recovery SMS also uses +// verification_code. The handler falls back to extracting the code from the +// rendered body when `otp` is null. +function(ctx) + local data = std.get(ctx, 'template_data', null); + local field(name) = if data == null then null else std.get(data, name, null); + { + recipient: ctx.recipient, + body: ctx.body, + otp: std.foldl( + function(found, name) if found != null then found else field(name), + ['login_code', 'registration_code', 'verification_code'], + null, + ), + template_type: std.get(ctx, 'template_type', null), + message_type: std.get(ctx, 'message_type', null), + } diff --git a/sms-providers/whatsapp/kratos-courier.yaml b/sms-providers/whatsapp/kratos-courier.yaml index 1587ac9..2b590ca 100644 --- a/sms-providers/whatsapp/kratos-courier.yaml +++ b/sms-providers/whatsapp/kratos-courier.yaml @@ -1,6 +1,9 @@ # Snippet to merge into the Kratos identity-config under /courier/channels. # Replace ORY_WEBHOOK_SECRET with the value you set in webhook/.env, and the URL # with your deployed handler URL. +# +# On Ory Network, set body to the Base64-encoded template instead: +# body: base64:// courier: channels: - id: sms diff --git a/sms-providers/whatsapp/registry.entry.yaml b/sms-providers/whatsapp/registry.entry.yaml index 56ecdb2..4bda72f 100644 --- a/sms-providers/whatsapp/registry.entry.yaml +++ b/sms-providers/whatsapp/registry.entry.yaml @@ -23,7 +23,7 @@ useCase: >- authentication-template message. coreFunctionality: |- - Kratos courier-spi HTTP target (configured via kratos-courier.yaml, not ory-actions.yaml). - - Extracts the OTP from the rendered Kratos body via strict regex /\b(?:code|pin|otp)[^\d]{0,16}(\d{4,10})\b/i. + - Takes the OTP from template_data (login_code, registration_code, or verification_code) via the body template, falling back to extracting it from the rendered Kratos body via strict regex /\b(?:code|pin|otp)[^\d]{0,16}(\d{4,10})\b/i. - Normalizes recipient to E.164 (leading "+" then digits). - POSTs to graph.facebook.com/{version}/{phone_id}/messages with type: template, sending the OTP as both the body parameter and the copy-code button parameter. - 422 otp_extract_failed when the message body doesn't yield a digit run — explicit instead of silently sending an empty template. diff --git a/sms-providers/whatsapp/webhook/server.ts b/sms-providers/whatsapp/webhook/server.ts index 5ea73db..208f0e1 100644 --- a/sms-providers/whatsapp/webhook/server.ts +++ b/sms-providers/whatsapp/webhook/server.ts @@ -34,6 +34,7 @@ if (!ORY_WEBHOOK_SECRET || !PHONE_NUMBER_ID || !ACCESS_TOKEN) { interface KratosCourierRequest { recipient?: string; body?: string; + otp?: string | null; [field: string]: unknown; } @@ -93,7 +94,10 @@ app.post( return; } - const otp = extractOtp(body); + const otp = + typeof req.body?.otp === "string" && /^\d{4,10}$/.test(req.body.otp) + ? req.body.otp + : extractOtp(body); if (!otp) { // We could fall back to a regular text message here, but the Cloud API's // text type requires a 24-hour customer-initiated session window. OTPs go