Fingerly.Server.AspNetCore
0.1.0
See the version list below for details.
dotnet add package Fingerly.Server.AspNetCore --version 0.1.0
NuGet\Install-Package Fingerly.Server.AspNetCore -Version 0.1.0
<PackageReference Include="Fingerly.Server.AspNetCore" Version="0.1.0" />
<PackageVersion Include="Fingerly.Server.AspNetCore" Version="0.1.0" />
<PackageReference Include="Fingerly.Server.AspNetCore" />
paket add Fingerly.Server.AspNetCore --version 0.1.0
#r "nuget: Fingerly.Server.AspNetCore, 0.1.0"
#:package Fingerly.Server.AspNetCore@0.1.0
#addin nuget:?package=Fingerly.Server.AspNetCore&version=0.1.0
#tool nuget:?package=Fingerly.Server.AspNetCore&version=0.1.0
fingerly-dotnet
Server-side event reads, webhook verification and a same-origin proxy for .NET.
dotnet add package Fingerly.Server
dotnet add package Fingerly.Server.AspNetCore # for the proxy and DI
using Fingerly;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFingerly(options =>
{
options.SecretKey = builder.Configuration["Fingerly:SecretKey"]!;
});
var app = builder.Build();
app.MapGet("/orders/{requestId}/check", async (string requestId, FingerlyClient fingerly, CancellationToken ct) =>
{
Event ev = await fingerly.Events.GetAsync(requestId, ct);
return ev.IsScored ? Results.Ok(ev.SuspectLevel) : Results.Ok("unscored");
});
app.Run();
EventPage page = await fingerly.Events.ListAsync(
new EventListOptions { Level = "high", Limit = 50 }, cancellationToken);
Event reads require fly_sk_{production|staging|development}_… keys. Requests
go to https://api.fingerly.io; Endpoint overrides that for a local
API. Public, proxy, management, malformed and unsupported keys are rejected
before network access, by the constructor rather than by the first call.
Requests do not follow redirects. GetAsync and ListAsync each perform one
request and never retry. An empty, . or .. request id is an
ArgumentException.
Packages
| Package | Contains | For |
|---|---|---|
Fingerly.Server |
client, webhooks, proxy core | everything |
Fingerly.Server.AspNetCore |
middleware, endpoint routing, DI | the proxy |
Fingerly.Server targets net8.0 and netstandard2.0. On net8.0 it has no
dependencies at all, because System.Text.Json is part of the shared
framework; on netstandard2.0 that one package is the only dependency, which
is what keeps the client and webhook verification reachable from .NET
Framework 4.6.2 and newer. Fingerly.Server.AspNetCore targets net8.0 and
takes ASP.NET Core as a framework reference, so nothing here collides with
what your application server already has.
Both packages are marked trimmable and AOT-safe. Every type is bound through
the System.Text.Json source generator rather than reflection, so a trimmed or
natively compiled application gets no warnings from this SDK and no binding
that the trimmer can quietly break.
Everything is asynchronous
There is no synchronous counterpart to any call, on purpose. Blocking a
request thread on I/O is what exhausts a thread pool under load, and the
deadlock that .Result can cause is not one an SDK could warn about at the
call site. Never call .Result or .Wait() on these methods.
Deadlines, limits and connections
Timeout bounds the whole exchange, including receiving the body: a server
that sends headers and then trickles cannot hold the call past it. It is
applied with a linked CancellationTokenSource rather than by writing to
HttpClient.Timeout, so handing the client a shared HttpClient does not
rewrite a timeout that belongs to whoever else is using it.
The two reasons a call can stop early stay distinguishable. A deadline throws
FingerlyTimeoutException; a CancellationToken that you cancelled raises
OperationCanceledException, which is what the caller who cancelled expects
to catch. HttpClient reports both as TaskCanceledException on its own, so
telling them apart is the SDK's job rather than yours.
An answer larger than 4 MiB is refused rather than buffered, whatever
HttpClient sent it: the client throws FingerlyException, the proxy answers
502.
Every client and proxy in a process shares one connection pool, so building
one per request still reuses connections, and the pool recycles connections
every two minutes so DNS changes are picked up. It keeps no cookie jar, never
follows a redirect, and copies none of your application's traceparent,
tracestate or baggage onto requests to Fingerly. AddFingerly configures
the IHttpClientFactory handler the same way. If you pass your own
Handler, give it AllowAutoRedirect = false, UseCookies = false, automatic
decompression and
ActivityHeadersPropagator = DistributedContextPropagator.CreateNoOutputPropagator().
Errors
Every exception derives from FingerlyException.
try
{
ev = await fingerly.Events.GetAsync(requestId, ct);
}
catch (FingerlyApiException e) when (e.IsPending)
{
return "retry"; // not decided yet: read again after e.RetryAfter seconds
}
catch (FingerlyApiException e) when (e.IsEventNotFound)
{
return "refuse"; // never issued, or past retention
}
FingerlyApiException is thrown for every non-2xx answer, redirects
included. It carries Status, and optional Code, Details, RequestId and
RetryAfter: the seconds Retry-After asked for, or null. The secret key is
stripped from all of them, and a response body is never echoed, not even in
an inner exception.
A read answers 404 with one of two codes, each with its own check:
IsPending:event_pending, the request id was accepted and the event is still being recorded. Nothing is decided yet: read again afterRetryAfterseconds (the API sendsRetry-After: 2), or verify asynchronously. Do not treat it as fraud.IsEventNotFound:event_not_found, the request id was never issued, or the event is past retention.
The client never retries by itself. A deadline throws
FingerlyTimeoutException; an unreachable API throws
FingerlyConnectionException.
No SDK object prints a credential: ToString() shows [redacted] in place of
every key, and serializing the options leaves the keys out.
Unscored events
Event.SuspectScore is int?, null when the request was not scored. Never
read null as zero: a visitor about whom nothing fired scores 0, while a
submission that was never enriched has no score at all. IsScored and
TryGetScore state the distinction at the call site.
if (!ev.IsScored)
{
// Decide on something else; do not treat it as clean.
}
Raw keeps the response body exactly as it arrived, so a field the API adds
later is reachable without an SDK upgrade.
Read an event you just identified
An event is usually readable as soon as identification answers. If it is
still being recorded, the read throws a FingerlyApiException whose
IsPending is true and whose RetryAfter says when to read again:
Event ev;
try
{
ev = await fingerly.Events.GetAsync(requestId, ct);
}
catch (FingerlyApiException e) when (e.IsPending)
{
// Not recorded yet, so nothing is decided: read again after
// e.RetryAfter seconds (2 when null), or verify asynchronously.
return "retry";
}
Retry a pending read a bounded number of times, for example from a background
job. IsEventNotFound means a request id that was never issued, or one past
retention, and is never pending.
The archived document arrives moments after the event itself, so check
HasDocument rather than assuming it is there.
Verify a backend action
Read the event with a server-only key. Check that its tag matches your action,
that it is recent, that its environment is correct, and that it was scored.
Apply your own policy to the server's verdict. A pending event
(IsPending) is not a verdict: retry after RetryAfter seconds or verify
asynchronously, rather than refusing the action. Do not authorize an action using
scores or visitor identifiers supplied by the browser. Tags correlate events;
they do not replace your application's authentication or replay protection.
Webhooks
string secret = builder.Configuration["Fingerly:WebhookSecret"]!;
app.MapPost("/webhooks/fingerly", async (HttpRequest request, CancellationToken ct) =>
{
using var body = new MemoryStream();
await request.Body.CopyToAsync(body, ct);
byte[] payload = body.ToArray();
if (!Webhooks.Verify(secret, payload,
request.Headers["x-fingerly-timestamp"],
request.Headers["x-fingerly-signature"]))
{
return Results.BadRequest();
}
WebhookEvent ev = Webhooks.Parse(payload);
// Queue ev.Id and payload for processing, deduplicating on ev.Id.
return Results.Ok();
});
Verify the exact raw bytes before parsing them. Reading the body into a
string and encoding it again is not the same thing: any byte that is not
valid UTF-8 becomes U+FFFD on the way through, and the digest then never
matches. Signatures cover the timestamp and body. The default replay tolerance
is five minutes; persist processed Id values to deduplicate deliveries.
During signing-secret rotation, any matching signature in the comma-separated
header is accepted; at most 32 are read. Malformed or missing input is a false
result, never an exception.
WebhookEvent.Data stays a JsonElement, so a new event type needs no SDK
upgrade.
Same-origin proxy
Content blockers refuse browser requests to third-party APIs, and a refused identification is a visitor never identified. Mount the proxy on a path of your own site and those requests become first-party. The proxy key is what lets that hop declare the visitor's real address, origin and user agent; a public key alone never can, so nobody can spoof them.
Create a proxy in the dashboard (Integration > Proxy keys). It issues the
key and a random path such as /k7x2qm4a; use that path as it is.
builder.Services.AddFingerlyProxy(options =>
{
options.ProxyKey = builder.Configuration["Fingerly:ProxyKey"]!;
options.Path = "/k7x2qm4a";
options.ClientIp = request => request.Header("CF-Connecting-IP") ?? request.RemoteAddress;
});
ClientIpis the one thing you must get right. Take the header your CDN or load balancer sets when it is there, and the connection's peer address (RemoteAddress) otherwise. Trust that header only when traffic can reach your server through nothing but that CDN or load balancer. Never read the left-mostX-Forwarded-Forvalue: the visitor writes it.
ClientIp must return exactly one IPv4 or IPv6 address. Anything else (null,
empty, a list, a port, a host name, or an exception) is a configuration error:
the request is refused with a 500 saying "The client IP callback returned no
valid address", nothing is sent upstream, and the mistake is logged once.
Then mount it, either as a routed endpoint:
app.MapFingerlyProxy();
or as middleware, which suits an application that does not use endpoint routing:
app.UseFingerlyProxy();
Prefer the mapped endpoint in a minimal-API application: the path becomes an
ordinary endpoint, so rate limiting, CORS and output caching apply to it the
way they apply to everything else. It is mapped with AllowAnonymous(), so a
fallback authorization policy does not send signed-out visitors to a sign-in
page: the browser SDK carries no user. Put the middleware ahead of
UseAuthentication() for the same reason.
Then point the browser SDK at the same path:
const fingerly = await load({ apiKey: 'fly_pk_…', endpoints: '/k7x2qm4a' })
What the proxy does
The browser SDK posts both of its calls to that one path, and the proxy
forwards each to Fingerly's one ingest URL, https://api.fingerly.io/api/v1/ingest
(ApiVersion changes the version there and nowhere else). It answers only
POST to the path itself, exactly: case-sensitive, and compared with the raw
request target as the visitor sent it, so a trailing or doubled slash, a dot
segment or percent encoding is a different path. The middleware passes such a
request on to your application; the mapped endpoint answers it 404. Anything
under the path gets the same, and any other method gets 405. Event reads
need a secret key and stay unreachable from a browser by construction.
The browser must present a public key from the same environment as the proxy
key, or the request gets 401. Bodies over 1 MiB get 413, enforced
while reading rather than after buffering. Only the content type, the visitor
metadata Fingerly needs, and an Idempotency-Key are forwarded; customer
cookies, authorization headers and trace context never are. The proxy reports
itself upstream as dotnet/0.1.0, which the dashboard shows per proxy.
Only Content-Type, X-Request-Id, RateLimit-Limit, RateLimit-Remaining
and Retry-After are relayed back, with Cache-Control: no-store added. A
blocker can match a response header by name, so this is an allowlist: nothing
on the response names Fingerly, whatever the API adds later. The answer is
read whole before anything is relayed, so a truncated one is never passed on
as a success.
When the upstream fails, the browser gets 504 ("Gateway timeout") if it did
not answer within Timeout (5 seconds by default), and 502 ("Bad gateway")
for anything else: refused, reset, truncated, larger than 4 MiB, or
undecodable. One line is logged per failure, without a stack trace or a key:
AddFingerlyProxy sends it to the application's ILogger (category
Fingerly.FingerlyProxy) at warning level, and FingerlyProxyOptions.Log
takes any other sink. A visitor who disconnects cancels the upstream call, and
the request ends quietly.
The middleware asks proxy.Handles(path) before reading anything. It sees
every request in the pipeline, and calling HandleAsync to find out whether
one belongs to Fingerly would have consumed a request body the application
still needs. In ASP.NET Core that body is a forward-only stream unless the
application opted into buffering, so the mistake would be silent rather than
loud. For the same reason the middleware refuses a proxy with no Path.
Path is the path within your application. It excludes anything
UsePathBase or an IIS virtual directory adds, because the proxy matches
Request.Path, which is application-relative in every host. It is optional
where routing already decides the path: leave it unset and give the path to
MapFingerlyProxy instead, which also works inside a route group.
app.MapGroup("/shop").MapFingerlyProxy("/k7x2qm4a");
Setting it in both places is refused, so the two cannot disagree. Use the path
the dashboard issued rather than one of your own: blockers look for words such
as fingerprint, tracking and analytics.
Outside ASP.NET Core
FingerlyProxy speaks to no server API. It reads a ProxyRequest (with
RemoteAddress set to the connection's peer) and returns a ProxyResponse,
so an isolated-worker Azure Function, a YARP transform or an OWIN pipeline can
mount it by translating those two shapes. Where that host routes only the one
path to it, Path can stay unset. That is also why the core still runs on
.NET Standard 2.0.
Development and release preparation
dotnet build # both target frameworks, warnings as errors
dotnet test # against a real Kestrel server
dotnet pack -c Release -o artifacts
dotnet publish tools/TrimCheck -c Release -r osx-arm64 -o trimmed && ./trimmed/TrimCheck
FINGERLY_CONTRACT_DIR=../../contract dotnet run --project tools/SyncContract
Warnings are errors, and XML documentation is generated, so an undocumented public member fails the build the way an unused variable does. A warning the SDK ships is one its users cannot fix.
The tests drive a real Kestrel server rather than a stubbed
HttpMessageHandler or HttpContext. A double shares the SDK's assumptions
about framing, path bases and bodies that can only be read once, so it cannot
disprove any of them.
The vendored synthetic contract snapshot makes tests independent of sibling
repositories. Its hashes are checked by ContractTest, so there is no separate
check step. Never put production secrets in fixtures.
Source structure
FingerlyClient holds the options; EventsService owns the reads and the list
filters, and Event, EventPage and EventListOptions are what they speak
in. Webhooks verifies signatures. FingerlyProxy holds every proxy decision,
with ProxyRequest and ProxyResponse as the neutral shapes the hosts
translate to. SharedHttp is the process-wide connection pool and reads every
answer within its limit. Keys validates every caller-supplied option before
any network access, and Json keeps the serializer context in one place so a
caller's own JsonSerializerOptions cannot change how responses are read.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Fingerly.Server (>= 0.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.