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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ Native support for popular web frameworks.
| Next.js | Middleware package | ✅ Active | Community |
| Express.js | Middleware package | ✅ Active | Community |
| FastAPI | Middleware package | ✅ Active | Community |
| [ASP.NET Core Minimal API](examples/frameworks/aspnetcore-minimal-api/) | Endpoint filter example for APort policy checks | ✅ Active | Community |
| Django | Middleware package | 🚧 In Progress | Community |
| Laravel | Composer package | 📋 Planned | Community |
| Rails | Ruby gem | 📋 Planned | Community |
Expand Down
7 changes: 7 additions & 0 deletions examples/frameworks/aspnetcore-minimal-api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copy these values into your shell or secret store before running against the live APort API.
# The sample runs in mock mode by default so it can be tested without credentials.

APort__BaseUrl=https://api.aport.io
APort__ApiKey=your_api_key_here
APort__DefaultPolicy=finance.payment.refund.v1
APort__UseMock=false
111 changes: 111 additions & 0 deletions examples/frameworks/aspnetcore-minimal-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# APort ASP.NET Core Minimal API Example

This example shows how to protect an ASP.NET Core Minimal API endpoint with an APort policy check before business logic runs.

Integration highlight: the `/refunds` endpoint uses an endpoint filter to call APort `/verify` with the agent ID from `X-Agent-Id`. If APort denies the policy check, the endpoint returns `403` and the refund handler is not executed.

## What is included

- `src/Program.cs` - a self-contained Minimal API app.
- `IAportVerificationClient` - a small typed `HttpClient` wrapper for APort `/verify`.
- `RequireAportVerification(...)` - an endpoint filter extension that protects selected routes.
- Mock mode for local development, enabled by default.

## Prerequisites

- .NET 10 SDK
- An APort API key for live verification

## Run locally

```bash
cd examples/frameworks/aspnetcore-minimal-api/src
dotnet run
```

The app listens on the URL printed by `dotnet run`, usually `http://localhost:5xxx`.

## Try the public health endpoint

```bash
curl http://localhost:5000/health
```

If your local port is different, replace `5000` with the port from `dotnet run`.

## Try the protected refund endpoint in mock mode

Mock mode is enabled by default so the example works without credentials.

Allowed mock request:

```bash
curl -X POST http://localhost:5000/refunds \
-H "Content-Type: application/json" \
-H "X-Agent-Id: agt_demo" \
-d '{"orderId":"ord_123","amount":42.50,"currency":"USD"}'
```

Denied mock request:

```bash
curl -X POST http://localhost:5000/refunds \
-H "Content-Type: application/json" \
-H "X-Agent-Id: agt_denied" \
-d '{"orderId":"ord_123","amount":42.50,"currency":"USD"}'
```

## Use the live APort API

Set these values in your shell or secret store:

```bash
export APort__BaseUrl="https://api.aport.io"
export APort__ApiKey="your_api_key_here"
export APort__DefaultPolicy="finance.payment.refund.v1"
export APort__UseMock="false"
```

On PowerShell:

```powershell
$env:APort__BaseUrl="https://api.aport.io"
$env:APort__ApiKey="your_api_key_here"
$env:APort__DefaultPolicy="finance.payment.refund.v1"
$env:APort__UseMock="false"
```

The live request body sent to APort is:

```json
{
"policy": "finance.payment.refund.v1",
"agentId": "agt_demo",
"context": {
"path": "/refunds",
"method": "POST"
}
}
```

## Configuration

| Key | Default | Description |
| --- | --- | --- |
| `APort__BaseUrl` | `https://api.aport.io` | APort API base URL. |
| `APort__ApiKey` | empty | API key used as a Bearer token for live verification. |
| `APort__DefaultPolicy` | `finance.payment.refund.v1` | Policy checked by the protected endpoint. |
| `APort__UseMock` | `true` | Keeps the sample runnable without credentials. Set to `false` for live API calls. |

## How to reuse this pattern

1. Register a typed `HttpClient` for APort verification.
2. Add `RequireAportVerification("your.policy.id")` to any Minimal API endpoint that needs policy enforcement.
3. Pass an agent identifier through a trusted header, token claim, or service-to-service identity mechanism.
4. Keep secrets in environment variables, user secrets, or a production secret manager; do not commit API keys.

## Notes

- The example intentionally avoids third-party packages so it can be copied into existing ASP.NET Core apps.
- The endpoint filter returns Problem Details responses for missing agent IDs, denied policies, and API failures.
- In a production app, bind the agent identity from your authentication layer instead of trusting a raw client-supplied header.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
172 changes: 172 additions & 0 deletions examples/frameworks/aspnetcore-minimal-api/src/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<AportOptions>(builder.Configuration.GetSection("APort"));
builder.Services.AddHttpClient<IAportVerificationClient, AportVerificationClient>();

var app = builder.Build();

var defaultPolicy = app.Configuration["APort:DefaultPolicy"] ?? "finance.payment.refund.v1";

app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
service = "aport-aspnetcore-minimal-api-example"
}));

app.MapPost("/refunds", (RefundRequest request) => Results.Ok(new
{
message = "Refund request passed APort verification and is ready for business processing.",
request.OrderId,
request.Amount
}))
.RequireAportVerification(defaultPolicy)
.WithName("CreateRefund")
.WithSummary("Create a refund after APort policy verification");

app.Run();

public sealed record RefundRequest(string OrderId, decimal Amount, string Currency = "USD");

public sealed class AportOptions
{
public string BaseUrl { get; init; } = "https://api.aport.io";
public string? ApiKey { get; init; }
public string DefaultPolicy { get; init; } = "finance.payment.refund.v1";

// Keeps the example runnable out of the box. Set APort__UseMock=false for the live API.
public bool UseMock { get; init; } = true;
}

public sealed record AportVerificationResult(bool Allow, IReadOnlyList<string> Reasons, JsonElement? Passport);

public interface IAportVerificationClient
{
Task<AportVerificationResult> VerifyAsync(
string policy,
string agentId,
object? context,
CancellationToken cancellationToken);
}

public sealed class AportVerificationClient(
HttpClient httpClient,
IOptions<AportOptions> options,
ILogger<AportVerificationClient> logger) : IAportVerificationClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly AportOptions _options = options.Value;

public async Task<AportVerificationResult> VerifyAsync(
string policy,
string agentId,
object? context,
CancellationToken cancellationToken)
{
if (_options.UseMock)
{
return MockVerification(agentId);
}

if (string.IsNullOrWhiteSpace(_options.ApiKey))
{
throw new InvalidOperationException("APort__ApiKey is required when APort__UseMock=false.");
}

var baseUri = new Uri(_options.BaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, "verify"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
request.Content = JsonContent.Create(new
{
policy,
agentId,
context
}, options: JsonOptions);

using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("APort verification returned {StatusCode}", response.StatusCode);
return new AportVerificationResult(
Allow: false,
Reasons: [$"APort API returned HTTP {(int)response.StatusCode}."],
Passport: null);
}

var verification = await response.Content.ReadFromJsonAsync<AportVerifyResponse>(JsonOptions, cancellationToken);
var allow = verification?.Allow ?? verification?.Verified ?? false;
var reasons = verification?.Reasons ?? [];

return new AportVerificationResult(allow, reasons, verification?.Passport);
}

private static AportVerificationResult MockVerification(string agentId)
{
if (agentId.Equals("agt_denied", StringComparison.OrdinalIgnoreCase))
{
return new AportVerificationResult(false, ["Mock denial for demonstration."], null);
}

return new AportVerificationResult(true, ["Mock allow for local development."], null);
}
}

public sealed class AportVerifyResponse
{
[JsonPropertyName("allow")]
public bool? Allow { get; init; }

[JsonPropertyName("verified")]
public bool? Verified { get; init; }

[JsonPropertyName("reasons")]
public string[] Reasons { get; init; } = [];

[JsonPropertyName("passport")]
public JsonElement? Passport { get; init; }
}

public static class AportEndpointFilterExtensions
{
public static RouteHandlerBuilder RequireAportVerification(this RouteHandlerBuilder builder, string policy)
{
return builder.AddEndpointFilter(async (context, next) =>
{
var httpContext = context.HttpContext;
var agentId = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();

if (string.IsNullOrWhiteSpace(agentId))
{
return Results.Problem(
title: "Missing APort agent identity",
detail: "Send the agent identifier in the X-Agent-Id header.",
statusCode: StatusCodes.Status400BadRequest);
}

var client = httpContext.RequestServices.GetRequiredService<IAportVerificationClient>();
var result = await client.VerifyAsync(policy, agentId, new
{
path = httpContext.Request.Path.Value,
method = httpContext.Request.Method
}, httpContext.RequestAborted);

if (!result.Allow)
{
return Results.Problem(
title: "APort verification denied",
detail: result.Reasons.Count > 0
? string.Join(" ", result.Reasons)
: "The agent is not allowed to perform this action.",
statusCode: StatusCodes.Status403Forbidden);
}

httpContext.Items["APortPassport"] = result.Passport;
return await next(context);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"APort": {
"BaseUrl": "https://api.aport.io",
"ApiKey": "",
"DefaultPolicy": "finance.payment.refund.v1",
"UseMock": true
}
}