Skip to content
Written with Claude

Changelog v3.15.0

Version 3.15.0 (2026-05-11)

Full Changelog

This release prepares NpgsqlRest to act as an external Web API service for a separate partner or frontend application. Three pieces land together:

  1. Auth fix — named cookie schemes registered under Auth:Schemes (introduced in 3.13.0) now actually authenticate incoming requests; previously they signed users in but no endpoint accepted the cookie.
  2. Cookie attributesCookieSameSite and CookieSecure config knobs on both the main and named cookie schemes, so cookie-based auth works across origins (SPA on a different domain).
  3. OpenAPI filtering — config-level filters (IncludeSchemas / ExcludeSchemas / NameSimilarTo / NameNotSimilarTo / RequiresAuthorizationOnly) plus a per-routine @openapi hide / @openapi tag <name> comment annotation, so a single host can serve a curated OpenAPI document to partners while keeping internal endpoints out of the spec.

Changes are concentrated in NpgsqlRestClient/Builder.cs, plugins/NpgsqlRest.OpenApi/, and NpgsqlRest/Defaults/CommentParsers/. No breaking changes; existing appsettings.json works as-is — every new key defaults to "no filter" or "ASP.NET default".

Configurations using Auth:Schemes to register a named cookie scheme alongside the main CookieAuth issued the named-scheme cookie correctly on login but no endpoint would authenticate against it. A request bearing only the named-scheme cookie was treated as anonymous — framework endpoints like /api/passkey/add/options returned 401, and SQL endpoints annotated @authorize returned 401. The feature signed users in but the sign-in was functionally inert.

Root cause

ASP.NET's authentication middleware only runs against the default authenticate scheme. The 3.13.0 implementation:

  1. Counted only the three main auth types (cookie / bearer / jwt) when choosing the default scheme — named schemes were invisible to that calculation.
  2. Registered the policy scheme (the dispatcher that picks the right scheme per request) only when more than one of the three main types was enabled. A typical setup with cookies + a named cookie scheme skipped the dispatcher entirely.
  3. Even when the dispatcher ran, its ForwardDefaultSelector only distinguished Bearer-vs-cookie header type. For any cookie-bearing request it returned the main cookie scheme regardless of which cookie was actually present.

The result: named-scheme cookies hit the main scheme's cookie handler, which couldn't decrypt them (different data-protection purpose strings per scheme), so context.User came out anonymous.

What changed

In NpgsqlRestClient/Builder.cs:

  • Pre-scan Auth:Schemes for enabled Cookie-type entries before AddAuthentication runs, so the default-scheme decision can account for them.
  • Register a policy scheme whenever the system has either (a) multiple main auth types — the existing case, unchanged — or (b) the main cookie scheme plus one or more named cookie schemes. For (b), a synthetic policy-scheme name (NpgsqlRest_PolicyScheme) is used to avoid colliding with the main cookie scheme's own registration.
  • Cookie-aware dispatchForwardDefaultSelector now walks the registered cookie schemes in order (main first, then named in registration order) and returns the first scheme whose configured cookie name appears in the request. Falls back to the main cookie scheme for cookie-less requests so anonymous traffic behaves exactly as before. Bearer/JWT header dispatch is unchanged.
  • Cookie-name tracking — every cookie scheme registration (main and named) now records its effective HTTP cookie name on Builder.CookieSchemesInOrder. Schemes without an explicit CookieName are tracked under ASP.NET's per-scheme default (.AspNetCore.<schemeName>), so the lookup is well-defined for both explicit and defaulted cookie names.

Behavior after the fix

  • A request carrying only a named-scheme cookie authenticates under that scheme. context.User.Identity.IsAuthenticated is true, context.User.Identity.AuthenticationType matches the named scheme name.
  • /api/passkey/add/options, /api/passkey/add, bearer/JWT refresh paths, and any @authorize-annotated SQL endpoint accept named-scheme cookies the same way they accept main-scheme cookies. No endpoint changes were required.
  • @authorize <role> continues to gate by role claims — a named-scheme cookie whose principal lacks the required role is still rejected. Scheme membership is orthogonal to role membership.
  • Backward compatibility is bit-for-bit identical for single-scheme configurations (cookies only, no Auth:Schemes): no policy scheme is registered, no selector logic engages, and the default authenticate scheme remains the main cookie scheme's name.

When a request somehow carries both a main cookie and a named-scheme cookie (rare in practice — a user is signed in under at most one scheme by SignInAsync), the walk order is main first, then named schemes in registration order, and the first match wins. This is deterministic but not configurable; if you need scheme-specific endpoint binding regardless of which cookie is present, ASP.NET's [Authorize(AuthenticationSchemes = "...")] is the right primitive and is out of scope for this release.

Feature: CookieSameSite and CookieSecure config

ASP.NET defaults the auth cookie's SameSite attribute to Lax and the Secure policy to SameAsRequest. That works for "browser and API on the same origin" but silently breaks the cross-origin case — an SPA on app.example.com calling an API on api.example.com won't have its session cookie sent on cross-site requests at all under Lax, and a None cookie without Secure is dropped outright by modern browsers.

Two new config keys make this controllable without dropping to a custom host.

jsonc
jsonc
"Auth": {
  "CookieAuth": true,
  "CookieSameSite": "None",       // "Strict" | "Lax" | "None" | "Unspecified" | null
  "CookieSecure":   "Always"      // "SameAsRequest" | "Always" | "None" | null
}

Default for both is null, which leaves ASP.NET's per-handler default in place — so existing configs see no change.

Per-scheme override under Auth:Schemes

The same two keys are accepted inside any Auth:Schemes:<name> Cookies-type entry, with the same inheritance pattern as the existing cookie fields (CookiePath, CookieDomain, CookieMultiSessions, CookieHttpOnly): scheme-level value wins, else inherit the root Auth section's value, else fall through to ASP.NET's default.

jsonc
jsonc
"Auth": {
  "CookieAuth": true,
  "CookieSameSite": "None",
  "CookieSecure":   "Always",
  "Schemes": {
    // Long-lived "remember me" cookie inherits the cross-origin posture from root.
    "remember_me":   { "Type": "Cookies", "CookieValid": "30 days" },

    // Short-lived sensitive-flow cookie tightens to first-party only.
    "short_session": {
      "Type": "Cookies",
      "CookieValid": "1 hour",
      "CookieSameSite": "Strict",
      "CookieSecure":   "SameAsRequest"
    }
  }
}

Validation and warnings

  • Unknown values fail fast at startup with the offending config path included in the message — typos in security-relevant config shouldn't be silently ignored. Example: Invalid value 'Loose' for Auth:CookieSameSite. Expected one of: Unspecified, None, Lax, Strict.
  • Setting SameSite=None without Secure=Always logs a startup warning at Warning level: browsers drop SameSite=None cookies that lack the Secure attribute, and the symptom ("login succeeds but the next request is anonymous") is otherwise hard to diagnose, especially during local HTTP testing.
  • Existing appsettings.json files are unaffected — both keys default to null (use ASP.NET's default).

Cross-origin checklist for an external Web API setup

Combining the cookie attributes above with the already-existing CORS support, a typical "API used by a separate SPA" config looks like:

jsonc
jsonc
"Cors": {
  "Enabled": true,
  "AllowedOrigins": ["https://app.example.com"],   // not "*"
  "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  "AllowedHeaders": ["*"],
  "AllowCredentials": true                          // required for cookie auth
},
"Auth": {
  "CookieAuth": true,
  "CookieSameSite": "None",                         // cross-site
  "CookieSecure":   "Always",                       // required when SameSite=None
  "CookieHttpOnly": true,
  "CookieDomain":   ".example.com"                  // optional — share across subdomains
}

For mobile or non-browser clients the bearer/JWT path remains the recommended route (no cookie attributes apply, no CORS preflight); these new knobs only matter when the client is a browser on a different origin.

Feature: OpenAPI filtering for partner-facing documents

The NpgsqlRest.OpenApi plugin previously documented every endpoint NpgsqlRest registered, with no way to suppress an endpoint or override its tag. That works for "internal API doc" but not for "API doc handed to a partner team" — where you typically want to expose only a curated subset (e.g. routines in a partner schema, only the authenticated surface, with a partner tag for nice Swagger UI grouping).

3.15.0 adds the missing controls: five config-level filters on OpenApiOptions plus a per-routine @openapi comment annotation. All work additively — every existing config keeps its current output, since defaults are "no filter".

Config-level filters

KeyTypeDefaultBehavior
IncludeSchemasstring[]empty = no filterAllow-list of schema names. Only endpoints whose routine schema appears here are documented.
ExcludeSchemasstring[]empty = no filterDeny-list of schema names. Applied alongside IncludeSchemas — both must pass.
NameSimilarTostringnull = no filterPostgreSQL-style SIMILAR TO pattern matched against the routine name. _ matches one char, % matches any sequence; `
NameNotSimilarTostringnull = no filterSame syntax as above but for exclusion. Applied alongside NameSimilarTo.
RequiresAuthorizationOnlyboolfalseWhen true, only RequiresAuthorization-bearing endpoints are documented — health/login/probes drop out.

These all live in the same place existing knobs do — under NpgsqlRest:OpenApiOptions in appsettings.json for the standalone client, or on OpenApiOptions for library users.

Per-routine @openapi comment annotation

Two sub-commands; both are no-ops when the OpenAPI plugin isn't loaded, so they're safe to leave on a routine regardless of how the host is configured.

AnnotationEffect
@openapi, @openapi hide, @openapi hidden, @openapi ignoreExclude this routine from the OpenAPI document. HTTP endpoint stays functional — only the spec entry is skipped.
@openapi tag <name>, @openapi tags <a>, <b>Replace the default schema-name tag with the supplied value(s). Drives section grouping in Swagger UI / ReDoc; tag values preserve original casing.
sql
sql
-- Hidden from the document; endpoint is still reachable internally.
comment on function refresh_materialized_views() is '
HTTP POST
@authorize admin
@openapi hide
';

-- Grouped under "Partner API" in Swagger UI instead of the default "public" schema tag.
comment on function partner_get_orders(_partner_id text) is '
HTTP GET /api/partner/orders
@authorize partner
@openapi tag Partner API
';

Filter order and composition

Filters are checked in OpenApi.Handle() in this order. The first one that rejects short-circuits — the rest don't run. Multiple filters compose conjunctively (all must pass for an endpoint to be documented).

  1. OpenApiHide annotation on the endpoint (per-routine wins over everything)
  2. RequiresAuthorizationOnly vs. RequiresAuthorization
  3. IncludeSchemas membership
  4. ExcludeSchemas membership
  5. NameSimilarTo match
  6. NameNotSimilarTo match (negative)
  7. → endpoint documented

Partner-facing config example

The full "API server, partner-facing OpenAPI document, internal endpoints invisible" config:

jsonc
jsonc
"NpgsqlRest": {
  "OpenApiOptions": {
    "Enabled": true,
    "FileName": "openapi-partner.json",
    "UrlPath": "/openapi/partner.json",
    "DocumentTitle": "Acme Partner API",
    "DocumentDescription": "JWT-authenticated REST surface for partner integrations.",

    "IncludeSchemas": ["partner"],                  // only partner-namespaced routines
    "RequiresAuthorizationOnly": true,              // drop health, login, probes
    "NameNotSimilarTo": "%_admin",                  // drop partner_*_admin maintenance routines

    "SecuritySchemes": [
      { "Name": "bearerAuth", "Type": "Http", "Scheme": "Bearer", "BearerFormat": "JWT" }
    ],
    "Servers": [
      { "Url": "https://api.acme.com", "Description": "Production" }
    ]
  }
}

The same host can still serve the internal cookie-authenticated surface — only the document is partner-scoped. A later operational change (e.g. moving to a separate process per audience) doesn't break what's been advertised to partners, since the document is config-driven.

Tests

Three new test files, 41 new tests total. Total auth + OpenAPI test count: 145 (78 pre-existing auth + 19 cookie/policy from this release + 32 OpenAPI including 16 filter / 9 annotation / 7 pre-existing path-parameter tests).

  • NpgsqlRestTests/AuthTests/AuthPolicySchemeTests.cs (16 tests): CookieSchemesInOrder population, policy-scheme registration decisions across single/multi/named combinations, and ForwardDefaultSelector dispatch (named cookie → named scheme, main cookie → main, both → main wins per documented order, neither → main fallback, bearer header preserved, JWT three-part token preserved, named cookie in composite mode, default .AspNetCore.<scheme> cookie name).
  • NpgsqlRestTests/AuthTests/AuthCookieSameSiteSecureTests.cs (15 tests): parsing of all four SameSiteMode and three CookieSecurePolicy values (case-insensitive), invalid-value fail-fast on the root and on named schemes (with the offending path in the message), unset values preserving ASP.NET defaults, the cross-origin SameSite=None; Secure=Always pattern reaching CookieAuthenticationOptions, named-scheme inheritance from root, per-scheme override winning over root.
  • NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs (16 tests): per-filter coverage for OpenApiHide, RequiresAuthorizationOnly, IncludeSchemas, ExcludeSchemas, NameSimilarTo (prefix %, single-char _, anchoring, alternation (get|set)_%), NameNotSimilarTo, all-filters-together composition, plus OpenApiTags override of the default schema tag. Drives the plugin directly with synthetic RoutineEndpoints, asserting against the JSON file the plugin writes.
  • NpgsqlRestTests/OpenApiTests/OpenApiAnnotationTests.cs (9 tests): end-to-end through the global TestFixture's OpenAPI handler. Verifies all four aliases for @openapi hide (bare, hide, hidden, ignore), @openapi tag single + multi, original-casing preservation for tag values, and that the default schema tag is unaffected when no @openapi annotation is present.

Pre-existing auth-scheme tests (AuthSchemeRegistrationTests, AuthSchemeLoginTests, AuthLegacyFieldFailFastTests, AuthIntervalNotationTests) and OpenAPI path-parameter tests continue to pass unchanged.

Configuration summary

Two new optional Auth keys, mirrored under each Auth:Schemes:<name> Cookies-type entry:

KeyValuesDefaultPurpose
Auth:CookieSameSiteStrict / Lax / None / Unspecifiednull (ASP.NET default)SameSite attribute on the cookie. Use None for cross-origin SPA / mobile clients.
Auth:CookieSecureSameAsRequest / Always / Nonenull (ASP.NET default)When the cookie's Secure attribute is set. Required Always when SameSite is None.

Five new optional OpenAPI keys under NpgsqlRest:OpenApiOptions:

KeyValuesDefaultPurpose
IncludeSchemasstring[]empty = all schemasSchema allow-list for the OpenAPI document.
ExcludeSchemasstring[]empty = no exclusionSchema deny-list. Applied alongside IncludeSchemas.
NameSimilarTostring (SIMILAR TO)nullRoutine-name allow pattern.
NameNotSimilarTostring (SIMILAR TO)nullRoutine-name deny pattern.
RequiresAuthorizationOnlyboolfalseDocument only authenticated endpoints.

No keys removed or renamed.

Out of scope

  • The @authorize annotation continues not to accept a scheme name as a value. Pinning an endpoint to a specific authentication scheme is the job of ASP.NET's [Authorize(AuthenticationSchemes = "...")]; surfacing that through a comment annotation is a separate feature design.
  • SignOutAsync and challenge paths target a specific scheme by name in code, so no changes were needed to ForwardChallenge / ForwardSignOut selectors.
  • The sign-in path (login function returning scheme = '<name>') was already correct in 3.13.0 — this release does not touch it.

Partner-system integration readiness — what's still missing

3.15.0 covers the common case of partner integration: JWT Bearer auth, a curated OpenAPI document, and cross-origin cookie auth where applicable. For richer enterprise-grade external-API scenarios, the following capabilities are not yet built in and would land in a future release if there's demand:

  • Per-API-key rate limiting. The partitioned rate limiter exists, but partition keys are typically IP- or user-based today. A first-class "X-Api-Key per-tenant quota" needs custom partition logic — possible to build externally, not configurable out of the box.
  • Idempotency keys. Many partner APIs honor an Idempotency-Key request header so retried POST/PUT calls don't double-charge / double-create. NpgsqlRest has no built-in support; you'd model it in SQL (a seen_keys table consulted before the routine runs) or as custom middleware.
  • HMAC request signing. Some partner programs require body signing on top of JWT (e.g. Stripe-style Signature: t=…,v1=…). Not built in; would need a custom middleware that verifies the signature before NpgsqlRest dispatches.
  • Per-endpoint authentication-scheme binding. @authorize gates by role, not by which auth scheme issued the principal. If you need "partner JWT can hit /api/partner/* but the internal cookie session cannot," that's ASP.NET's [Authorize(AuthenticationSchemes = …)] plumbing — not yet exposed as a comment annotation.
  • Multiple OpenAPI documents per process. One host = one OpenAPI document. The new filters let you scope that document to a partner audience, but you can't serve partner.json and internal.json from the same process. Today: filter to one audience, or run two NpgsqlRest hosts. The plugin's IEndpointCreateHandler interface is single-instance.
  • API-key issuance and rotation flow. Partners rotate keys periodically. NpgsqlRest doesn't ship a key-management UX — you build the issue/rotate/revoke endpoints as ordinary SQL routines on top of the framework.
  • API versioning conventions. No built-in Accept-Version header or path-versioning convention. Today: separate routine names per version (v1_get_orders / v2_get_orders), or filter to a single version per host using the new NameSimilarTo knob.
  • Refresh-token rotation as a first-class story. Refresh paths exist for both BearerToken and JWT schemes, but "rotate on use" with sliding expiration and reuse detection is not documented as load-bearing. Treat as "works for the basic case; harden if you're under threat models that assume token theft."
  • Antiforgery posture for cookie-cross-origin. Antiforgery middleware is wired, but the interaction with SameSite=None cookies — when to require a double-submit token vs. when to rely on SameSite=Strict for state-changing routes — is not explicitly documented. If you go cookie-based cross-origin, audit this for your threat model.

None of these block a typical "partner team consumes our JWT API + a Swagger UI we host" integration. They're the next layer of polish if NpgsqlRest evolves further toward being a primary external-API platform.

Comments