Changelog v3.15.0
Version 3.15.0 (2026-05-11)
This release prepares NpgsqlRest to act as an external Web API service for a separate partner or frontend application. Three pieces land together:
- 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. - Cookie attributes —
CookieSameSiteandCookieSecureconfig knobs on both the main and named cookie schemes, so cookie-based auth works across origins (SPA on a different domain). - 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".
Fix: named cookie schemes now actually authenticate requests
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:
- Counted only the three main auth types (cookie / bearer / jwt) when choosing the default scheme — named schemes were invisible to that calculation.
- 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.
- Even when the dispatcher ran, its
ForwardDefaultSelectoronly distinguishedBearer-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:Schemesfor enabled Cookie-type entries beforeAddAuthenticationruns, 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 dispatch —
ForwardDefaultSelectornow 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 explicitCookieNameare 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.IsAuthenticatedistrue,context.User.Identity.AuthenticationTypematches 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.
Cookie-precedence order when both are present
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.
Root-level (main cookie scheme)
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
"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=NonewithoutSecure=Alwayslogs a startup warning at Warning level: browsers dropSameSite=Nonecookies that lack theSecureattribute, and the symptom ("login succeeds but the next request is anonymous") is otherwise hard to diagnose, especially during local HTTP testing. - Existing
appsettings.jsonfiles are unaffected — both keys default tonull(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
"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
| Key | Type | Default | Behavior |
|---|---|---|---|
IncludeSchemas | string[] | empty = no filter | Allow-list of schema names. Only endpoints whose routine schema appears here are documented. |
ExcludeSchemas | string[] | empty = no filter | Deny-list of schema names. Applied alongside IncludeSchemas — both must pass. |
NameSimilarTo | string | null = no filter | PostgreSQL-style SIMILAR TO pattern matched against the routine name. _ matches one char, % matches any sequence; ` |
NameNotSimilarTo | string | null = no filter | Same syntax as above but for exclusion. Applied alongside NameSimilarTo. |
RequiresAuthorizationOnly | bool | false | When 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.
| Annotation | Effect |
|---|---|
@openapi, @openapi hide, @openapi hidden, @openapi ignore | Exclude 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
-- 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).
OpenApiHideannotation on the endpoint (per-routine wins over everything)RequiresAuthorizationOnlyvs.RequiresAuthorizationIncludeSchemasmembershipExcludeSchemasmembershipNameSimilarTomatchNameNotSimilarTomatch (negative)- → endpoint documented
Partner-facing config example
The full "API server, partner-facing OpenAPI document, internal endpoints invisible" config:
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):CookieSchemesInOrderpopulation, policy-scheme registration decisions across single/multi/named combinations, andForwardDefaultSelectordispatch (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 fourSameSiteModeand threeCookieSecurePolicyvalues (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-originSameSite=None; Secure=Alwayspattern reachingCookieAuthenticationOptions, named-scheme inheritance from root, per-scheme override winning over root.NpgsqlRestTests/OpenApiTests/OpenApiFilterTests.cs(16 tests): per-filter coverage forOpenApiHide,RequiresAuthorizationOnly,IncludeSchemas,ExcludeSchemas,NameSimilarTo(prefix%, single-char_, anchoring, alternation(get|set)_%),NameNotSimilarTo, all-filters-together composition, plusOpenApiTagsoverride of the default schema tag. Drives the plugin directly with syntheticRoutineEndpoints, 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 tagsingle + multi, original-casing preservation for tag values, and that the default schema tag is unaffected when no@openapiannotation 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:
| Key | Values | Default | Purpose |
|---|---|---|---|
Auth:CookieSameSite | Strict / Lax / None / Unspecified | null (ASP.NET default) | SameSite attribute on the cookie. Use None for cross-origin SPA / mobile clients. |
Auth:CookieSecure | SameAsRequest / Always / None | null (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:
| Key | Values | Default | Purpose |
|---|---|---|---|
IncludeSchemas | string[] | empty = all schemas | Schema allow-list for the OpenAPI document. |
ExcludeSchemas | string[] | empty = no exclusion | Schema deny-list. Applied alongside IncludeSchemas. |
NameSimilarTo | string (SIMILAR TO) | null | Routine-name allow pattern. |
NameNotSimilarTo | string (SIMILAR TO) | null | Routine-name deny pattern. |
RequiresAuthorizationOnly | bool | false | Document only authenticated endpoints. |
No keys removed or renamed.
Out of scope
- The
@authorizeannotation 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. SignOutAsyncand challenge paths target a specific scheme by name in code, so no changes were needed toForwardChallenge/ForwardSignOutselectors.- 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-Keyrequest header so retried POST/PUT calls don't double-charge / double-create. NpgsqlRest has no built-in support; you'd model it in SQL (aseen_keystable 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.
@authorizegates 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.jsonandinternal.jsonfrom the same process. Today: filter to one audience, or run two NpgsqlRest hosts. The plugin'sIEndpointCreateHandlerinterface 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-Versionheader 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 newNameSimilarToknob. - 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=Nonecookies — when to require a double-submit token vs. when to rely onSameSite=Strictfor 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.