Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ First stable release. It replaces the 0.1.0 preview package completely.
### Added

- `ShieldLabs` and `AsyncShieldLabs` History API clients: `history.search`, `history.iter`
(de-duplicated on `request_id`) and `identifications.get`, which waits for the verdict:
(de-duplicated on `identification_id`) and `identifications.get`, which waits for the verdict:
- `timeout` (10 s by default) is the total budget of the call. Polls run immediately, then
after waits of `poll_interval` times 1, 2, 4, 6 and 8, then 8 again, each capped at 2 s, or
at `poll_interval` when that is longer (0.25 s, 0.5 s, 1 s, 1.5 s and every 2 s by default;
Expand Down
44 changes: 23 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ ShieldLabs identifies visitors and scores risk with device intelligence. New to

```
1. Browser 2. Your backend 3. Decision
ShieldLabs agent ---> receives requestId with the signup, ---> allow, step up,
returns requestId login or checkout; reads the verdict review or refuse
ShieldLabs agent ---> receives identificationId with the signup, ---> allow, step up,
returns identificationId login or checkout; reads the verdict review or refuse
with this SDK (History API) or gets it
as a signed identification.scored webhook
```
Expand Down Expand Up @@ -46,18 +46,20 @@ from typing import Optional
from shieldlabs import ShieldLabs, evaluate_identification, webhooks

client = ShieldLabs(api_key=os.environ["SHIELDLABS_API_KEY"]) # Private API Key, sec_...
used_request_ids: set[str] = set() # use your database or cache in production
used_identification_ids: set[str] = set() # use your database or cache in production


def allow_signup(request_id: str) -> bool:
def allow_signup(identification_id: str) -> bool:
# 1. Read the identification for the request ID the browser sent with the form.
# Scoring is asynchronous, so this waits (up to 10 s by default) for the verdict.
identification = client.identifications.get(request_id)
identification = client.identifications.get(identification_id)

# 2. Evaluate it: missing, reused, stale, rate-limited, automated or dangerous is refused.
verdict = evaluate_identification(identification, is_replay=lambda rid: rid in used_request_ids)
verdict = evaluate_identification(
identification, is_replay=lambda rid: rid in used_identification_ids
)
if identification is not None:
used_request_ids.add(identification.request_id)
used_identification_ids.add(identification.identification_id)
return verdict.ok


Expand All @@ -84,13 +86,13 @@ start filling in the signup form) rather than on submit, so the verdict is usual
your backend asks for it. An identification older than `max_age` (5 minutes by default) counts
as stale, so start a new one when the user comes back later.

`identifications.get` polls the History API by `request_id` until the row appears and returns
`identifications.get` polls the History API by `identification_id` until the row appears and returns
that first version:

```python
identification = client.identifications.get(request_id) # wait up to 10 s
identification = client.identifications.get(request_id, timeout=5) # shorter budget
identification = client.identifications.get(request_id, wait=False) # one lookup only
identification = client.identifications.get(identification_id) # wait up to 10 s
identification = client.identifications.get(identification_id, timeout=5) # shorter budget
identification = client.identifications.get(identification_id, wait=False) # one lookup only

if identification is None:
... # not scored in time: treat it as unverified, never as clean
Expand Down Expand Up @@ -121,9 +123,9 @@ How the wait works:
raises `BadRequestError`, `AuthenticationError` or `NotFoundError`.
- **`wait=False`** makes one lookup with the client's regular retries and returns `None` when
there is no row yet.
- `request_id` must be a UUID. An invalid value raises `ValidationError` before any request.
- `identification_id` must be a UUID. An invalid value raises `ValidationError` before any request.
- For the refined state (for example in a later review job), read the row again with
`client.history.search("request_id", request_id, limit=1)`.
`client.history.search("identification_id", identification_id, limit=1)`.

### Decide with `evaluate_identification`

Expand All @@ -133,7 +135,7 @@ this order, and reports the first one that fails:
| `reason` | Refused when |
|---|---|
| `missing` | there is no identification (unverified, never clean) |
| `replayed` | `is_replay(request_id)` returns `True` (one identification authorizes one action) |
| `replayed` | `is_replay(identification_id)` returns `True` (one identification authorizes one action) |
| `stale` | `observed_at` is older than `max_age` (default 300 seconds) |
| `rate_limited` | the Risk Score is the rate-limit marker (above 100, in practice 999) |
| `no_device_signals` | the device ID is the all-zero UUID `00000000-0000-0000-0000-000000000000` |
Expand Down Expand Up @@ -192,7 +194,7 @@ field names:

| Field | Type | Notes |
|---|---|---|
| `request_id`, `visitor_id`, `device_id`, `session_id`, `cookie_id` | `str` | UUIDs; the all-zero UUID is possible |
| `identification_id`, `visitor_id`, `device_id`, `session_id`, `cookie_id` | `str` | UUIDs; the all-zero UUID is possible |
| `user_hid` | `str` or `None` | your User HID; `"anonymous"` for anonymous checks |
| `domain` | `str` | the registered domain |
| `public_ip`, `local_ip` | `IpInfo(ip, country)` | IPv4 or `""`; `country` is an English country name such as `"Germany"`, or `""` |
Expand Down Expand Up @@ -230,12 +232,12 @@ if identification.has_device_signals: # the all-zero device ID matches unrelate
if item.user_hid not in NOT_ACCOUNTS
}
if len(accounts) > 2:
send_to_review(identification.request_id, accounts)
send_to_review(identification.identification_id, accounts)
```

| `type` | `value` |
|---|---|
| `request_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id` | a UUID (sent lowercase) |
| `identification_id`, `device_id`, `visitor_id`, `session_id`, `cookie_id` | a UUID (sent lowercase) |
| `user_hid` | a non-empty string, matched exactly and case-sensitively |
| `ip` | a dotted IPv4 address |

Expand Down Expand Up @@ -288,7 +290,7 @@ def handle_delivery(raw_body: bytes, signature: Optional[str]) -> int:
except WebhookParseError:
return 400
if isinstance(event, IdentificationScoredEvent):
if already_processed(event.data.request_id):
if already_processed(event.data.identification_id):
return 200
enqueue(event.data) # do slow work after responding
elif isinstance(event, WebhookPingEvent):
Expand All @@ -301,7 +303,7 @@ def handle_delivery(raw_body: bytes, signature: Optional[str]) -> int:
endpoint secret without downtime.
- ShieldLabs sends one delivery per identification and endpoint, with a 1-second timeout and no
retries. Respond with a 2xx within 1 second and do slow work afterwards.
- Make handlers idempotent on `data.request_id`: a future release retries deliveries, and a
- Make handlers idempotent on `data.identification_id`: a future release retries deliveries, and a
retry resends identical bytes.
- Use the History API for guaranteed reads and for the latest state: a delivery that fails is
not sent again, and a History row can be refined after its webhook was sent.
Expand Down Expand Up @@ -345,7 +347,7 @@ for 10 minutes. The client never retries a `429`, so call it sparingly and cache
from shieldlabs import AsyncShieldLabs

async with AsyncShieldLabs() as client: # reads SHIELDLABS_API_KEY
identification = await client.identifications.get(request_id)
identification = await client.identifications.get(identification_id)
async for item in client.history.iter("user_hid", account_hid, max_items=200):
...
```
Expand Down Expand Up @@ -376,7 +378,7 @@ its keys with the default hosts.
| Call | Returns |
|---|---|
| `ShieldLabs(api_key=None, base_url=None, timeout=10.0, max_retries=2, http_client=None)` | History API client |
| `client.identifications.get(request_id, wait=True, timeout=10.0, poll_interval=0.25)` | `Identification` or `None`; `timeout` is the total wait in seconds; `poll_interval` p sets the waits p, 2p, 4p, 6p, 8p, then 8p again, each at most 2 seconds, or p when p is longer |
| `client.identifications.get(identification_id, wait=True, timeout=10.0, poll_interval=0.25)` | `Identification` or `None`; `timeout` is the total wait in seconds; `poll_interval` p sets the waits p, 2p, 4p, 6p, 8p, then 8p again, each at most 2 seconds, or p when p is longer |
| `client.history.search(type, value, limit=20, offset=0)` | `HistoryPage(data, total)` |
| `client.history.iter(type, value, page_size=100, max_items=None)` | iterator of `Identification`; `max_items=0` yields nothing |
| `AsyncShieldLabs(...)` | same methods as coroutines; `history.iter` is an async iterator |
Expand Down
6 changes: 3 additions & 3 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

A FastAPI app that does both halves of a server integration:

- `POST /signup` reads the `requestId` sent by the browser, waits for the verdict with
- `POST /signup` reads the `identificationId` sent by the browser, waits for the verdict with
`identifications.get`, and refuses the signup when the identification is missing, reused,
stale, carries the rate-limit marker, has no device signals, shows browser automation or
disabled JavaScript, or falls in the dangerous band (`evaluate_identification` defaults).
- `POST /webhooks/shieldlabs` verifies `X-Shield-Signature` over the raw body, parses the event,
handles each `request_id` once (so a retried delivery is harmless), and logs it.
handles each `identification_id` once (so a retried delivery is harmless), and logs it.

Run it from the repository root:

Expand All @@ -26,7 +26,7 @@ Try the signup route with a request ID from your page:
```bash
curl -X POST http://localhost:8000/signup \
-H 'content-type: application/json' \
-d '{"email": "user@example.com", "requestId": "8f14e45f-ceea-4c1e-a3b2-1d2c3b4a5f60"}'
-d '{"email": "user@example.com", "identificationId": "8f14e45f-ceea-4c1e-a3b2-1d2c3b4a5f60"}'
```

To receive webhooks locally, expose port 8000 with an HTTPS tunnel and register
Expand Down
22 changes: 11 additions & 11 deletions examples/fastapi_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
# one request ID authorizes one protected action, and the webhook handler stays idempotent on
# the request ID (one delivery per identification today; a future release retries deliveries
# with identical bytes).
used_request_ids: set[str] = set()
processed_request_ids: set[str] = set()
used_identification_ids: set[str] = set()
processed_identification_ids: set[str] = set()


def _webhook_secrets() -> list[str]:
Expand All @@ -71,7 +71,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:

class SignupForm(BaseModel):
email: str
request_id: str = Field(alias="requestId")
identification_id: str = Field(alias="identificationId")


@app.post("/signup")
Expand All @@ -81,9 +81,9 @@ async def signup(form: SignupForm, request: Request) -> JSONResponse:
# Waits for the verdict (up to 10 s): the History row appears about 1-3 s after the
# browser call, so the page starts the identification when the user starts filling in
# the form rather than on submit.
identification = await client.identifications.get(form.request_id)
identification = await client.identifications.get(form.identification_id)
except ValidationError:
return JSONResponse({"error": "invalid_request_id"}, status_code=400)
return JSONResponse({"error": "invalid_identification_id"}, status_code=400)
except ShieldLabsError:
# Unverified is never clean: refuse and let the user retry.
logger.exception("ShieldLabs lookup failed")
Expand All @@ -94,10 +94,10 @@ async def signup(form: SignupForm, request: Request) -> JSONResponse:
# and the dangerous band. Tune it for your product.
verdict = evaluate_identification(
identification,
is_replay=lambda request_id: request_id in used_request_ids,
is_replay=lambda identification_id: identification_id in used_identification_ids,
)
if identification is not None:
used_request_ids.add(identification.request_id)
used_identification_ids.add(identification.identification_id)
if not verdict.ok:
logger.info(
"signup refused: reason=%s band=%s flag=%s", verdict.reason, verdict.band, verdict.flag
Expand All @@ -122,14 +122,14 @@ async def shieldlabs_webhook(request: Request) -> Response:

if isinstance(event, IdentificationScoredEvent):
data = event.data
if data.request_id in processed_request_ids:
if data.identification_id in processed_identification_ids:
return Response(status_code=200) # already handled: acknowledge and stop
processed_request_ids.add(data.request_id)
processed_identification_ids.add(data.identification_id)
# Answer fast (under 1 s): a slow or failed delivery is not sent again, so queue slow
# work instead of doing it here, and read the History API when you must not miss one.
logger.info(
"identification.scored request_id=%s risk_score=%s band=%s flags=%s",
data.request_id,
"identification.scored identification_id=%s risk_score=%s band=%s flags=%s",
data.identification_id,
data.risk_score,
data.risk_band,
",".join(data.detection_flags.active()) or "none",
Expand Down
Loading
Loading