diff --git a/.gitignore b/.gitignore
index bcd92b8..e73010c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,5 @@ ui/test-results/
ui/playwright-report/
src/EzOdata.Host/wwwroot/
.DS_Store
+deploy/compose/specs/*
+!deploy/compose/specs/.gitkeep
diff --git a/deploy/compose/demo.sh b/deploy/compose/demo.sh
new file mode 100755
index 0000000..2747f80
--- /dev/null
+++ b/deploy/compose/demo.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# Beat 2 of the demo: two unlike engines, one identical API contract.
+#
+# docker compose -f docker-compose.minimal.yml -f docker-compose.demo.yml up -d --build
+# ./demo.sh
+set -euo pipefail
+API=http://localhost:8080
+EMAIL=admin@example.com
+PASS='a-strong-password-1'
+
+say() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; }
+
+say "Wait for the platform"
+for i in $(seq 1 60); do curl -fsS "$API/health" >/dev/null 2>&1 && break || sleep 2; done
+
+say "First-run setup (ignore 409 if already done)"
+curl -fsS -X POST "$API/system/setup" -H 'Content-Type: application/json' \
+ -d "{\"email\":\"$EMAIL\",\"displayName\":\"Admin\",\"password\":\"$PASS\"}" >/dev/null 2>&1 || true
+
+say "Log in"
+TOKEN=$(curl -fsS -X POST "$API/system/auth/login" -H 'Content-Type: application/json' \
+ -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .accessToken)
+AUTH=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
+
+say "Register service 1 of 2 -> PostgreSQL"
+curl -fsS -X POST "$API/system/services" "${AUTH[@]}" -d '{
+ "name":"sales-pg","label":"Sales (PostgreSQL)","connectorType":"postgresql",
+ "connection":{"host":"sampledb","port":5432,"database":"northwind",
+ "username":"demo","password":"demo-pw","tls":{"mode":"disable"}}}' \
+ | jq -c '{name,connectorType,status}' 2>/dev/null || echo ' (already registered)'
+
+say "Register service 2 of 2 -> MySQL (different engine, same platform)"
+curl -fsS -X POST "$API/system/services" "${AUTH[@]}" -d '{
+ "name":"sales-my","label":"Sales (MySQL)","connectorType":"mysql",
+ "connection":{"host":"sampledb-mysql","port":3306,"database":"northwind",
+ "username":"demo","password":"demo-pw","tls":{"mode":"disable"}}}' \
+ | jq -c '{name,connectorType,status}' 2>/dev/null || echo ' (already registered)'
+
+say "Refresh schema on both (introspection) — wait for Active"
+for svc in sales-pg sales-my; do
+ ID=$(curl -fsS "$API/system/services" "${AUTH[@]}" | jq -r --arg n "$svc" '.[]|select(.name==$n)|.id')
+ curl -fsS -X POST "$API/system/services/$ID/refresh" "${AUTH[@]}" >/dev/null 2>&1 || true
+ ST=""
+ for i in $(seq 1 40); do
+ ST=$(curl -fsS "$API/system/services/$ID" "${AUTH[@]}" | jq -r .status)
+ case "$ST" in Active|Failed) break;; esac
+ sleep 2
+ done
+ printf ' %-9s id=%-3s status=%s\n' "$svc" "$ID" "$ST"
+done
+
+say "THE POINT: byte-for-byte identical OData query against both engines"
+for svc in sales-pg sales-my; do
+ printf '\n--- %s ---\n' "$svc"
+ curl -fsS -G "$API/api/odata/$svc/customers" "${AUTH[@]}" \
+ --data-urlencode '$filter=country eq '"'"'US'"'"'' \
+ --data-urlencode '$select=id,name,country' \
+ --data-urlencode '$orderby=id' \
+ --data-urlencode '$top=3' | jq -c '.value'
+done
+
+say "Same data over REST, and each service publishes OpenAPI 3.1"
+for svc in sales-pg sales-my; do
+ printf ' %-9s rest: %s | openapi paths: %s\n' "$svc" \
+ "$(curl -fsS -G "$API/api/rest/$svc/_table/customers" "${AUTH[@]}" --data-urlencode 'limit=1' 2>/dev/null | jq -c '.resource[0].name' 2>/dev/null || echo n/a)" \
+ "$(curl -fsS "$API/api/odata/$svc/openapi.json" "${AUTH[@]}" 2>/dev/null | jq '.paths|length' 2>/dev/null || echo n/a)"
+done
+
+say "Beat 3: interrogate the data — discovery endpoints"
+SVC=sales-pg
+printf ' tables : %s\n' "$(curl -fsS "$API/api/odata/$SVC/" "${AUTH[@]}" | jq -c '[.value[].name]')"
+printf ' $metadata : %s bytes of CSDL\n' "$(curl -fsS "$API/api/odata/$SVC/\$metadata" "${AUTH[@]}" | wc -c | tr -d ' ')"
+printf ' customers : %s\n' "$(curl -fsS "$API/api/rest/$SVC/_table/customers/_schema" "${AUTH[@]}" | jq -c '[.fields[]|"\(.name):\(.type)"]')"
+printf ' PII flagged : %s\n' "$(curl -fsS "$API/api/rest/$SVC/_table/customers/_schema" "${AUTH[@]}" | jq -c '[.fields[]|select(.description!=null)|{name,description}]')"
+
+say "Beat 3b: the same data over MCP (JSON-RPC, for LLM clients)"
+ROLE=$(curl -fsS "$API/system/roles" "${AUTH[@]}" | jq -r '.[0].id')
+curl -fsS -X POST "$API/system/apps" "${AUTH[@]}" \
+ -d "{\"name\":\"mcp-explorer\",\"description\":\"demo\",\"roleId\":$ROLE,\"isActive\":true,\"mcpEnabled\":true}" \
+ >/dev/null 2>&1 || true
+APPID=$(curl -fsS "$API/system/apps" "${AUTH[@]}" | jq -r '.[]|select(.name=="mcp-explorer")|.id')
+# The full key is returned exactly once, so mint a fresh one each run.
+KEY=$(curl -fsS -X POST "$API/system/apps/$APPID/keys" "${AUTH[@]}" -d '{"name":"demo"}' | jq -r .key)
+MCP=(-H "X-API-Key: $KEY" -H 'Content-Type: application/json')
+printf ' tools for key : %s\n' "$(curl -fsS "$API/mcp/health" -H "X-API-Key: $KEY" | jq -c .toolsAvailableForKey)"
+printf ' tools : %s\n' "$(curl -fsS -X POST "$API/mcp" "${MCP[@]}" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -c '[.result.tools[].name]')"
+printf ' sales-my_query: %s\n' "$(curl -fsS -X POST "$API/mcp" "${MCP[@]}" \
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sales-my_query","arguments":{"table":"customers","filter":"country='"'"'US'"'"'","fields":["id","name"],"limit":3}}}' \
+ | jq -c '.result.content[0].text|fromjson|.rows')"
+
+say "Beat 3c: browsable OpenAPI (Swagger UI on :8081)"
+mkdir -p "$(dirname "$0")/specs"
+for svc in sales-pg sales-my; do
+ for kind in odata rest; do
+ curl -fsS "$API/api/$kind/$svc/openapi.json" "${AUTH[@]}" > "$(dirname "$0")/specs/$kind-$svc.json"
+ done
+done
+printf ' exported %s specs -> ./specs/\n' "$(ls "$(dirname "$0")"/specs/*.json | wc -l | tr -d ' ')"
+printf ' open: http://localhost:8081 ("Try it out" works — CORS allows :8081)\n'
diff --git a/deploy/compose/docker-compose.demo.yml b/deploy/compose/docker-compose.demo.yml
new file mode 100644
index 0000000..7e0be25
--- /dev/null
+++ b/deploy/compose/docker-compose.demo.yml
@@ -0,0 +1,77 @@
+# Demo overlay on the MINIMAL profile (SQLite system DB = the EF-migration path that
+# is actually tested; the Postgres system-DB path uses EnsureCreated and currently
+# fails on a SQLite-style filtered index).
+#
+# Adds BOTH sample engines as connectable services — that is the demo's point:
+# two unlike backends, one identical API contract.
+#
+# docker compose -f docker-compose.minimal.yml -f docker-compose.demo.yml up -d --build
+# ./demo.sh
+services:
+ ez-odata-api:
+ # image runs as non-root ($APP_UID) but the named /data volume is root-owned,
+ # so SQLite cannot create its file. Demo-only workaround.
+ user: "root"
+ environment:
+ # base compose ships a 31-byte master key; the host requires exactly 32
+ Encryption__MasterKey: "Y29tcG9zZS1kZXYtbWFzdGVyLWtleS0zMmJ5dGVzISE="
+ Mcp__Enabled: "true"
+ # Preflight is anonymous (no X-API-Key), so Swagger UI's origin has to be on the
+ # instance default list for "Try it out" to work (spec 08 §7).
+ Cors__DefaultAllowedOrigins__0: "http://localhost:8081"
+ depends_on:
+ sampledb:
+ condition: service_healthy
+ sampledb-mysql:
+ condition: service_healthy
+
+ sampledb:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: northwind
+ POSTGRES_USER: demo
+ POSTGRES_PASSWORD: demo-pw
+ ports:
+ - "55432:5432"
+ volumes:
+ - ../../tests/EzOdata.ConformanceTests/Fixtures/northwind.sql:/docker-entrypoint-initdb.d/01-northwind.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U demo -d northwind"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ sampledb-mysql:
+ image: mariadb:11 # MySQL 8.4 defaults to caching_sha2_password, which the
+ # connector cannot negotiate without AllowPublicKeyRetrieval
+ environment:
+ MYSQL_DATABASE: northwind
+ MYSQL_USER: demo
+ MYSQL_PASSWORD: demo-pw
+ MARIADB_ROOT_PASSWORD: root-pw
+ MYSQL_ROOT_PASSWORD: root-pw
+ ports:
+ - "53306:3306"
+ volumes:
+ - ../../js/test/conformance/fixtures/northwind-mysql.sql:/docker-entrypoint-initdb.d/01-northwind.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -proot-pw || mysqladmin ping -h 127.0.0.1 -uroot -proot-pw"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
+ # Spec browser for the demo. The API requires auth on openapi.json, so Swagger UI
+ # cannot fetch it directly; ./demo.sh exports the four specs into ./specs and
+ # nginx serves them from there. Browsing works; "Try it out" needs CORS on the API.
+ swagger-ui:
+ image: swaggerapi/swagger-ui
+ ports:
+ - "8081:8080"
+ environment:
+ URLS: |
+ [{"url":"/specs/odata-sales-pg.json","name":"OData - sales-pg (PostgreSQL)"},
+ {"url":"/specs/odata-sales-my.json","name":"OData - sales-my (MySQL)"},
+ {"url":"/specs/rest-sales-pg.json","name":"REST - sales-pg (PostgreSQL)"},
+ {"url":"/specs/rest-sales-my.json","name":"REST - sales-my (MySQL)"}]
+ volumes:
+ - ./specs:/usr/share/nginx/html/specs:ro
diff --git a/deploy/compose/specs/.gitkeep b/deploy/compose/specs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/EzOdata.Admin/Auth/AuthService.cs b/src/EzOdata.Admin/Auth/AuthService.cs
index 9974fac..5faf119 100644
--- a/src/EzOdata.Admin/Auth/AuthService.cs
+++ b/src/EzOdata.Admin/Auth/AuthService.cs
@@ -170,6 +170,7 @@ private static string GenerateOpaqueToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48))
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
- internal static string Sha256(string value) =>
+ /// Key/token hash format, shared with CORS origin resolution in the host.
+ public static string Sha256(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
diff --git a/src/EzOdata.Host/Middleware/CorsOptions.cs b/src/EzOdata.Host/Middleware/CorsOptions.cs
new file mode 100644
index 0000000..88d44f7
--- /dev/null
+++ b/src/EzOdata.Host/Middleware/CorsOptions.cs
@@ -0,0 +1,13 @@
+namespace EzOdata.Host.Middleware;
+
+/// spec 08 §7 / spec 12 §4 — instance-level CORS defaults.
+public sealed class CorsOptions
+{
+ public const string SectionPath = "Cors";
+
+ ///
+ /// Origins allowed when the request presents no API key — notably browser preflight,
+ /// which never carries custom headers (spec 08 §7).
+ ///
+ public string[] DefaultAllowedOrigins { get; set; } = [];
+}
diff --git a/src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs b/src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs
new file mode 100644
index 0000000..0a348d9
--- /dev/null
+++ b/src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs
@@ -0,0 +1,181 @@
+using System.Text.Json;
+using EzOdata.Admin.Auth;
+using EzOdata.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace EzOdata.Host.Middleware;
+
+///
+/// Default-deny CORS (spec 08 §7). The allowed-origin list is dynamic — it comes from the
+/// App row behind the presented API key — so this replaces the static UseCors policy.
+/// Preflight carries no custom headers, so an unkeyed request falls back to the
+/// instance-level Cors:DefaultAllowedOrigins list.
+/// Runs before authentication: a preflight must succeed while still anonymous.
+///
+public sealed class DynamicCorsMiddleware
+{
+ private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
+
+ // OData + RateLimit headers must be readable by browser clients (spec 08 §7).
+ private const string ExposedHeaders =
+ "OData-Version,RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset,Retry-After,X-Request-Id";
+
+ private const string AllowedMethods = "GET,POST,PATCH,PUT,DELETE,OPTIONS";
+ private const string FallbackAllowedHeaders = "Authorization,Content-Type,X-API-Key,Prefer";
+
+ private readonly RequestDelegate _next;
+ private readonly IMemoryCache _cache;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly CorsOptions _options;
+
+ public DynamicCorsMiddleware(
+ RequestDelegate next, IMemoryCache cache, IServiceScopeFactory scopeFactory, IOptions options)
+ {
+ _next = next;
+ _cache = cache;
+ _scopeFactory = scopeFactory;
+ _options = options.Value;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var origin = context.Request.Headers.Origin.ToString();
+ if (string.IsNullOrEmpty(origin))
+ {
+ await _next(context); // same-origin or non-browser: nothing to negotiate
+ return;
+ }
+
+ var allowed = await ResolveAllowedOriginsAsync(context);
+ var isPreflight = HttpMethods.IsOptions(context.Request.Method) &&
+ !string.IsNullOrEmpty(context.Request.Headers.AccessControlRequestMethod);
+
+ if (!Matches(origin, allowed))
+ {
+ // Default deny: emit no CORS headers. The browser blocks it; a preflight
+ // is still answered 204 so the failure surfaces as CORS, not as a 401.
+ if (isPreflight)
+ {
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return;
+ }
+
+ await _next(context);
+ return;
+ }
+
+ var headers = context.Response.Headers;
+ headers.AccessControlAllowOrigin = origin;
+ headers.Append("Vary", "Origin");
+ headers.AccessControlExposeHeaders = ExposedHeaders;
+
+ if (isPreflight)
+ {
+ var requested = context.Request.Headers.AccessControlRequestHeaders.ToString();
+ headers.AccessControlAllowMethods = AllowedMethods;
+ headers.AccessControlAllowHeaders =
+ string.IsNullOrEmpty(requested) ? FallbackAllowedHeaders : requested;
+ headers.AccessControlMaxAge = "600";
+ context.Response.StatusCode = StatusCodes.Status204NoContent;
+ return; // never let a preflight reach authentication
+ }
+
+ await _next(context);
+ }
+
+ private async Task> ResolveAllowedOriginsAsync(HttpContext context)
+ {
+ var key = context.Request.Headers[ApiKeyAuthenticationOptions.HeaderName].ToString();
+ if (string.IsNullOrEmpty(key))
+ {
+ key = context.Request.Query[ApiKeyAuthenticationOptions.QueryName].ToString();
+ }
+
+ if (string.IsNullOrEmpty(key))
+ {
+ return _options.DefaultAllowedOrigins;
+ }
+
+ var hash = AuthService.Sha256(key);
+ var cacheKey = "ez:cors:" + hash;
+ if (_cache.TryGetValue(cacheKey, out string[]? cached) && cached is not null)
+ {
+ return cached;
+ }
+
+ var origins = await LookupAsync(hash, context.RequestAborted);
+ _cache.Set(cacheKey, origins, CacheTtl);
+ return origins;
+ }
+
+ private async Task LookupAsync(string hash, CancellationToken ct)
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var json = await db.ApiKeys
+ .AsNoTracking()
+ .Where(k => k.KeyHash == hash && k.RevokedAt == null && k.App!.IsActive)
+ .Select(k => k.App!.AllowedOriginsJson)
+ .FirstOrDefaultAsync(ct);
+
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ // Known key with no per-app list, or an unknown key: fall back to the
+ // instance default rather than leaking which keys exist.
+ return _options.DefaultAllowedOrigins;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(json) ?? _options.DefaultAllowedOrigins;
+ }
+ catch (JsonException)
+ {
+ return _options.DefaultAllowedOrigins;
+ }
+ }
+
+ /// Exact match, or a single leading wildcard label (https://*.example.com).
+ public static bool Matches(string origin, IReadOnlyList allowed)
+ {
+ foreach (var candidate in allowed)
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ continue;
+ }
+
+ if (string.Equals(candidate, origin, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var star = candidate.IndexOf("://*.", StringComparison.Ordinal);
+ if (star < 0)
+ {
+ continue;
+ }
+
+ // "https://*.example.com" -> scheme "https://", suffix ".example.com"
+ var scheme = candidate[..(star + 3)];
+ var suffix = candidate[(star + 4)..];
+ if (!origin.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) ||
+ !origin.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ // Require a non-empty label in place of the wildcard, and no extra dots in it.
+ var label = origin[scheme.Length..^suffix.Length];
+ if (label.Length > 0 && !label.Contains('.'))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/EzOdata.Host/Program.cs b/src/EzOdata.Host/Program.cs
index 7650a51..6a9b37c 100644
--- a/src/EzOdata.Host/Program.cs
+++ b/src/EzOdata.Host/Program.cs
@@ -31,6 +31,8 @@
builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionPath));
builder.Services.Configure(builder.Configuration.GetSection(LockoutOptions.SectionPath));
+builder.Services.Configure(
+ builder.Configuration.GetSection(EzOdata.Host.Middleware.CorsOptions.SectionPath));
// ---- System database (SQLite default; PostgreSQL planned for prod) ----
var systemDbProvider = builder.Configuration["SystemDatabase:Provider"] ?? "sqlite";
@@ -240,6 +242,7 @@
// ---- Pipeline (spec 02 §4 order) ----
app.UseMiddleware();
app.UseMiddleware();
+app.UseMiddleware(); // spec 02 §4 step 4 — before authn; preflight is anonymous
app.UseMiddleware();
app.UseAuthentication();
app.UseAuthorization();
diff --git a/tests/EzOdata.IntegrationTests/CorsTests.cs b/tests/EzOdata.IntegrationTests/CorsTests.cs
new file mode 100644
index 0000000..6f6a252
--- /dev/null
+++ b/tests/EzOdata.IntegrationTests/CorsTests.cs
@@ -0,0 +1,164 @@
+using System.Net;
+using System.Net.Http.Json;
+using EzOdata.Host.Middleware;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Xunit;
+
+namespace EzOdata.IntegrationTests;
+
+/// spec 08 §7 — default-deny CORS with per-app origin whitelists.
+public class CorsTests : IClassFixture
+{
+ private readonly CorsHostFixture _fixture;
+
+ public CorsTests(CorsHostFixture fixture) => _fixture = fixture;
+
+ ///
+ /// Standalone host fixture — HostFixture is sealed, and this one additionally needs
+ /// an instance-level default origin configured before the host boots.
+ ///
+ public sealed class CorsHostFixture : WebApplicationFactory
+ {
+ public const string DefaultOrigin = "http://localhost:8081";
+
+ private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"ez-cors-{Guid.NewGuid():N}.db");
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment("Testing");
+ builder.UseSetting("SystemDatabase:Provider", "sqlite");
+ builder.UseSetting("SystemDatabase:ConnectionString", $"Data Source={_dbPath}");
+ builder.UseSetting("Auth:Jwt:SigningKey", "integration-test-signing-key-32-chars!!");
+ builder.UseSetting("Encryption:MasterKey",
+ Convert.ToBase64String("integration-test-master-key-32b!"u8.ToArray()));
+ builder.UseSetting("Cors:DefaultAllowedOrigins:0", DefaultOrigin);
+ }
+
+ public async Task CreateAdminClientAsync()
+ {
+ const string email = "admin@example.com";
+ const string password = "a-strong-test-password-1";
+ var client = CreateClient();
+
+ var status = await client.GetFromJsonAsync("/system/setup");
+ if (status!.Required)
+ {
+ var setup = await client.PostAsJsonAsync("/system/setup",
+ new { email, displayName = "Test Admin", password });
+ setup.EnsureSuccessStatusCode();
+ }
+
+ var login = await client.PostAsJsonAsync("/system/auth/login", new { email, password });
+ login.EnsureSuccessStatusCode();
+ var auth = await login.Content.ReadFromJsonAsync();
+ client.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", auth!.AccessToken);
+ return client;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (File.Exists(_dbPath)) File.Delete(_dbPath);
+ }
+
+ private sealed record SetupStatusDto(bool Required);
+
+ private sealed record AuthDto(string AccessToken);
+ }
+
+ private static HttpRequestMessage Preflight(string origin, string path = "/api/odata/x/customers")
+ {
+ var request = new HttpRequestMessage(HttpMethod.Options, path);
+ request.Headers.Add("Origin", origin);
+ request.Headers.Add("Access-Control-Request-Method", "GET");
+ return request;
+ }
+
+ [Fact]
+ public async Task Preflight_from_default_origin_is_allowed_while_anonymous()
+ {
+ var client = _fixture.CreateClient();
+
+ var response = await client.SendAsync(Preflight(CorsHostFixture.DefaultOrigin));
+
+ // Must not be 401: a browser preflight never carries credentials.
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ Assert.Equal(CorsHostFixture.DefaultOrigin,
+ Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin")));
+ Assert.Contains("OData-Version",
+ Assert.Single(response.Headers.GetValues("Access-Control-Expose-Headers")));
+ }
+
+ [Fact]
+ public async Task Preflight_from_unknown_origin_gets_no_cors_headers()
+ {
+ var client = _fixture.CreateClient();
+
+ var response = await client.SendAsync(Preflight("https://evil.example"));
+
+ Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
+ }
+
+ [Fact]
+ public async Task Request_without_origin_is_untouched()
+ {
+ var client = _fixture.CreateClient();
+
+ var response = await client.GetAsync("/healthz/live");
+
+ Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
+ }
+
+ [Fact]
+ public async Task Per_app_origin_is_honored_for_keyed_request()
+ {
+ var admin = await _fixture.CreateAdminClientAsync();
+ var roles = await admin.GetFromJsonAsync>("/system/roles");
+ var created = await admin.PostAsJsonAsync("/system/apps", new
+ {
+ name = "cors-app",
+ roleId = roles![0].Id,
+ isActive = true,
+ allowedOrigins = new[] { "https://*.example.com" },
+ });
+ created.EnsureSuccessStatusCode();
+ var app = await created.Content.ReadFromJsonAsync();
+ var keyResponse = await admin.PostAsJsonAsync($"/system/apps/{app!.Id}/keys", new { name = "k" });
+ var key = (await keyResponse.Content.ReadFromJsonAsync())!.Key;
+
+ var client = _fixture.CreateClient();
+ var request = new HttpRequestMessage(HttpMethod.Get, "/api/odata/x/customers");
+ request.Headers.Add("Origin", "https://tenant.example.com");
+ request.Headers.Add("X-API-Key", key);
+
+ var response = await client.SendAsync(request);
+
+ Assert.Equal("https://tenant.example.com",
+ Assert.Single(response.Headers.GetValues("Access-Control-Allow-Origin")));
+ }
+
+ [Theory]
+ // exact
+ [InlineData("https://app.example.com", "https://app.example.com", true)]
+ [InlineData("https://APP.example.com", "https://app.example.com", true)] // origin compare is case-insensitive
+ [InlineData("https://other.example.com", "https://app.example.com", false)]
+ // single-label wildcard
+ [InlineData("https://*.example.com", "https://tenant.example.com", true)]
+ [InlineData("https://*.example.com", "https://a.b.example.com", false)] // wildcard spans one label only
+ [InlineData("https://*.example.com", "http://tenant.example.com", false)] // scheme must match
+ [InlineData("https://*.example.com", "https://example.com", false)] // wildcard needs a label
+ [InlineData("https://*.example.com", "https://evilexample.com", false)] // suffix forgery
+ [InlineData("https://*.example.com", "https://evil.com/?x=.example.com", false)]
+ public void Matches_follows_spec_08_section_7(string allowed, string origin, bool expected)
+ => Assert.Equal(expected, DynamicCorsMiddleware.Matches(origin, new[] { allowed }));
+
+ [Fact]
+ public void Empty_allow_list_denies_everything()
+ => Assert.False(DynamicCorsMiddleware.Matches("https://app.example.com", Array.Empty()));
+
+ private sealed record RoleDto(long Id);
+ private sealed record AppDto(long Id);
+ private sealed record KeyDto(string Key);
+}