ArrowLabs.Auth.Client
0.1.0
dotnet add package ArrowLabs.Auth.Client --version 0.1.0
NuGet\Install-Package ArrowLabs.Auth.Client -Version 0.1.0
<PackageReference Include="ArrowLabs.Auth.Client" Version="0.1.0" />
<PackageVersion Include="ArrowLabs.Auth.Client" Version="0.1.0" />
<PackageReference Include="ArrowLabs.Auth.Client" />
paket add ArrowLabs.Auth.Client --version 0.1.0
#r "nuget: ArrowLabs.Auth.Client, 0.1.0"
#:package ArrowLabs.Auth.Client@0.1.0
#addin nuget:?package=ArrowLabs.Auth.Client&version=0.1.0
#tool nuget:?package=ArrowLabs.Auth.Client&version=0.1.0
ArrowLabs.Auth.Client
.NET client for the ArrowLabs Auth platform. Drop-in JWT bearer authentication for ASP.NET Core APIs that are protected by ArrowLabs-issued access tokens, plus typed accessors for the platform claims.
Access tokens are RS256 JWTs validated offline against the platform's JWKS — your API never calls the auth service on the hot path.
Status: v1 ships JWT validation + claims helpers, the OAuth client (PKCE flow, token exchange, refresh, revoke), and the RabbitMQ event consumer (typed handlers, idempotency, DLQ-on-failure). NuGet packaging lands in a subsequent slice.
Install
dotnet add package ArrowLabs.Auth.Client
Targets net10.0.
Quickstart
Register the scheme and add the standard auth middleware:
using System.Security.Claims;
using ArrowLabs.Auth.Client;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddArrowLabsAuth(options =>
{
options.BaseUrl = "https://api.arrowlabs.co.uk"; // your auth API base URL
options.Audience = "your-client-id"; // the aud your tokens are minted for
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/me", (ClaimsPrincipal user) => new
{
userId = user.GetUserId(),
org = user.GetOrgSlug(),
roles = user.GetRoles(),
}).RequireAuthorization();
app.Run();
AddArrowLabsAuth configures ASP.NET Core's JWT bearer handler with Authority = BaseUrl — it discovers /.well-known/openid-configuration, resolves the JWKS endpoint, and handles key fetch/caching/rotation for you. It validates the signature (RS256 only), iss, aud, and exp.
Options
| Option | Required | Default | Description |
|---|---|---|---|
BaseUrl |
✓ | — | Auth API base URL (no trailing slash). Used as the JWT Authority + expected issuer. |
Audience |
✓ | — | Expected aud claim — your application's client_id. |
RequireHttpsMetadata |
true |
Require HTTPS for metadata/JWKS. Set false only for local http://localhost development. |
// Local development against a non-HTTPS auth API:
builder.Services.AddArrowLabsAuth(options =>
{
options.BaseUrl = "http://localhost:5116";
options.Audience = "your-client-id";
options.RequireHttpsMetadata = false;
});
Authorization
Standard [Authorize] works out of the box. Roles are mapped from the roles claim, so role checks work too:
[Authorize] // any authenticated caller
[Authorize(Roles = "admin")] // requires the "admin" role
public class ReportsController : ControllerBase { /* ... */ }
User.Identity.Name is the user id (the sub claim).
Claims
Typed extension methods on ClaimsPrincipal map the platform's claim names so you don't hand-read raw strings:
string? userId = User.GetUserId(); // sub
string? orgId = User.GetOrgId(); // org_id
string? orgSlug = User.GetOrgSlug(); // org
string? email = User.GetEmail(); // email
IReadOnlyList<string> roles = User.GetRoles(); // roles (empty if none)
IReadOnlyList<string> appAccess = User.GetAppAccess(); // app_access (empty if none)
Single-value accessors return null when the claim is absent; collection accessors return an empty list. The raw claim names are also exposed as constants on ArrowLabsClaimTypes.
OAuth client
For apps that complete the OAuth redirect flow server-side (confidential or public/PKCE clients), register the OAuth client:
builder.Services.AddArrowLabsOAuthClient(options =>
{
options.BaseUrl = "https://api.arrowlabs.co.uk";
options.ClientId = "your-client-id";
options.ClientSecret = "your-client-secret"; // omit for public (PKCE-only) clients
});
Inject IArrowLabsOAuthClient where you need it.
1. Start the flow (PKCE + redirect)
app.MapGet("/login", (IArrowLabsOAuthClient oauth, HttpContext ctx) =>
{
var pkce = Pkce.Generate();
var state = Guid.NewGuid().ToString("N");
// Stash the verifier + state in the user's session — you'll need them at the callback.
ctx.Session.SetString("pkce_verifier", pkce.Verifier);
ctx.Session.SetString("oauth_state", state);
var url = oauth.BuildAuthorizationUrl(
codeChallenge: pkce.Challenge,
redirectUri: "https://app.example.com/callback",
state: state);
return Results.Redirect(url);
});
2. Handle the callback (exchange the code)
app.MapGet("/callback", async (string code, string state, IArrowLabsOAuthClient oauth, HttpContext ctx) =>
{
if (state != ctx.Session.GetString("oauth_state"))
return Results.BadRequest("state mismatch");
var result = await oauth.ExchangeCodeAsync(
code,
redirectUri: "https://app.example.com/callback",
codeVerifier: ctx.Session.GetString("pkce_verifier"));
return result switch
{
TokenResult.Success s => Results.Ok(new { s.AccessToken, s.ExpiresIn, s.RefreshToken }),
TokenResult.Failure f => Results.BadRequest(new { f.Error, f.ErrorDescription }),
_ => Results.StatusCode(500),
};
});
3. Refresh and revoke
// Rotate tokens (the refresh token rotates on every use):
var refreshed = await oauth.RefreshTokensAsync(currentRefreshToken);
// Revoke a refresh token (e.g. on sign-out). Idempotent — returns Success even if unknown:
var revoked = await oauth.RevokeTokenAsync(currentRefreshToken);
Both token operations return the same TokenResult union (Success / Failure); transport failures surface as Failure("network_error", …), so there's a single error path with no try/catch for expected outcomes.
Event consumer
For backends that react to Platform Events (user registered, suspended, roles changed, app deleted, …), the library hosts a RabbitMQ consumer that drains your dedicated queue and dispatches typed payloads to handlers you register in DI. It uses RabbitMQ.Client with automatic connection recovery.
The platform's dispatcher provisions a durable per-app queue arrowlabs.events.{your_client_id} and binds it to the event types you subscribe to. The consumer attaches to that queue — it never declares or binds topology.
1. Implement handlers
using ArrowLabs.Auth.Client;
public sealed class ProvisionOnRegister(IAccountService accounts)
: IArrowLabsEventHandler<UserRegisteredPayload>
{
public Task HandleAsync(UserRegisteredPayload payload, EventEnvelope envelope, CancellationToken ct)
=> accounts.ProvisionAsync(payload.UserId, payload.Email, ct);
}
public sealed class DisableOnSuspend(IAccountService accounts)
: IArrowLabsEventHandler<UserSuspendedPayload>
{
public Task HandleAsync(UserSuspendedPayload payload, EventEnvelope envelope, CancellationToken ct)
=> accounts.DisableAsync(payload.UserId, ct);
}
Handlers are resolved per-message from a DI scope, so they can inject scoped services. Multiple handlers per event type are allowed.
2. Register the consumer and handlers
builder.Services.AddArrowLabsEventConsumer(options =>
{
options.AmqpUrl = builder.Configuration["Amqp:Url"]!; // amqps://user:pass@host:5671
options.ClientId = "your-client-id"; // → queue arrowlabs.events.your-client-id
options.PrefetchCount = 10; // max in-flight; default 10
});
builder.Services.AddArrowLabsEventHandler<ProvisionOnRegister>();
builder.Services.AddArrowLabsEventHandler<DisableOnSuspend>();
The consumer runs as an IHostedService — it starts and stops with your app.
Delivery & failure semantics
- Ack on success. When every handler for a message resolves, the message is acknowledged.
- Failure → dead-letter, no requeue. If a handler throws (or the body can't be decoded), the message is nacked without requeue — the broker routes it to the dead-letter queue. No poison-message loops. Failed events are inspectable and retryable from the admin portal's DLQ view.
Idempotency
Events are at-least-once — redelivery happens. Make handlers idempotent. Every EventEnvelope carries a unique EventId; register an IArrowLabsEventDeduplicator to wire it to your own store:
public sealed class RedisDeduplicator(IConnectionMultiplexer redis) : IArrowLabsEventDeduplicator
{
public async Task<bool> IsDuplicateAsync(string eventId, CancellationToken ct) =>
await redis.GetDatabase().KeyExistsAsync($"evt:{eventId}");
public Task MarkProcessedAsync(string eventId, CancellationToken ct) =>
redis.GetDatabase().StringSetAsync($"evt:{eventId}", "1", TimeSpan.FromDays(7));
}
// builder.Services.AddSingleton<IArrowLabsEventDeduplicator, RedisDeduplicator>();
IsDuplicateAsync is checked before handlers run (a duplicate is acked and skipped); MarkProcessedAsync runs after they all succeed, before the ack. The SDK ships no store — the seam is yours.
The full event catalogue is exposed as PlatformEventTypes constants, and every payload (UserRegisteredPayload, UserSuspendedPayload, …) is a strongly-typed record.
How validation works
- Offline. Tokens are validated cryptographically against the JWKS — no call to the auth API per request.
- RS256 only. The accepted algorithm is pinned to
RS256, blocking algorithm-confusion attacks. - No revocation check. Access tokens are short-lived (15 minutes); revocation is handled by expiry + refresh-token rotation, not by this validator.
Versioning
Follows semver. Published to NuGet from this repository on vX.Y.Z tags.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.7)
- RabbitMQ.Client (>= 7.2.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.