Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/test-verification-integrations.yml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 26 additions & 19 deletions email-providers/brevo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,32 +32,34 @@ In the Ory Console under **Email & SMS → Email** or via CLI:
ory patch identity-config \
--project <your-project-id> \
--workspace <your-workspace-id> \
--replace '/courier/smtp/connection_uri="smtps://<brevo-smtp-login>:<brevo-smtp-key>@smtp-relay.brevo.com:587"' \
--replace '/courier/smtp/connection_uri="smtp://<brevo-smtp-login>:<brevo-smtp-key>@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 <your-project-id> \
--workspace <your-workspace-id> \
--replace '/courier/channels=[{
"id": "email",
"type": "http",
"request_config": {
"url": "https://api.brevo.com/v3/smtp/email",
"method": "POST",
"headers": {
"api-key": "<brevo-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": "<brevo-api-key>", "in": "header" }
},
"body": "base64://'"$(base64 < brevo-body.jsonnet | tr -d '\n')"'"
}'
```

`brevo-body.jsonnet`:
Expand All @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions email-providers/brevo/registry.entry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_login>:<smtp_key>@smtp-relay.brevo.com:587 (STARTTLS) or :465 (TLS).
- REST API alternative: POST https://api.brevo.com/v3/smtp/email with header `api-key: <brevo-api-key>` and a Jsonnet body wired via Kratos's HTTP courier.
- SMTP relay (recommended): smtp://<smtp_login>:<smtp_key>@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: <brevo-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.
Expand Down
47 changes: 28 additions & 19 deletions email-providers/sparkpost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,34 +30,38 @@ Ory Kratos courier → SparkPost SMTP (smtp.sparkpostmail.com:587) → recipient
ory patch identity-config \
--project <your-project-id> \
--workspace <your-workspace-id> \
--replace '/courier/smtp/connection_uri="smtps://SMTP_Injection:<sparkpost-api-key>@smtp.sparkpostmail.com:587"' \
--replace '/courier/smtp/connection_uri="smtp://SMTP_Injection:<sparkpost-api-key>@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 <your-project-id> \
--workspace <your-workspace-id> \
--replace '/courier/channels=[{
"id": "email",
"type": "http",
"request_config": {
"url": "https://api.sparkpost.com/api/v1/transmissions",
"method": "POST",
"headers": {
"authorization": "<sparkpost-api-key>",
"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": "<sparkpost-api-key>", "in": "header" }
},
"body": "base64://'"$(base64 < sparkpost-body.jsonnet | tr -d '\n')"'"
}'
```

`sparkpost-body.jsonnet`:
Expand All @@ -68,20 +72,25 @@ 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

| 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 |
Expand Down
4 changes: 2 additions & 2 deletions email-providers/sparkpost/registry.entry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:<API_KEY>@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: <API_KEY>` and a Jsonnet body.
- SMTP relay (recommended): smtp://SMTP_Injection:<API_KEY>@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: <API_KEY>` 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).
Expand Down
17 changes: 10 additions & 7 deletions fraud-bot-protection/castle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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://<encoded>`.
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
Expand Down
32 changes: 15 additions & 17 deletions fraud-bot-protection/castle/jsonnet/login.jsonnet
Original file line number Diff line number Diff line change
@@ -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,
}
32 changes: 15 additions & 17 deletions fraud-bot-protection/castle/jsonnet/registration.jsonnet
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading