Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5b6d238
Feat: Add missing security scheme types
rian-be Sep 9, 2026
db332dd
Feat: Adjust scheme mapping and package config
rian-be Sep 9, 2026
c10a58d
test(security): add security-scheme tests and update package
rian-be Sep 9, 2026
1d4c667
refactor(openapi): add plugin contract and scheme validation
rian-be Sep 11, 2026
7925c73
test(security): prevent OAuth2 and OpenID Connect enum aliasing
rian-be Sep 11, 2026
d83a9b2
test(security): add host and plugin security scheme tests
rian-be Sep 11, 2026
dd69b2b
refactor(security): stabilize auth scheme enums and metadata
rian-be Sep 11, 2026
edc6bfa
feat(security): support JSON and form API key extraction
rian-be Sep 11, 2026
35fefd9
feat(security): introduce API key extraction pipeline
rian-be Sep 11, 2026
3fe9973
feat(security): add API key extraction and plugin validation
rian-be Sep 11, 2026
8325c98
feat(security): add security scheme ADRs and OpenAPI mapper
rian-be Sep 11, 2026
df5d9b1
Merge remote-tracking branch 'origin/development' into area/security-…
rian-be Sep 11, 2026
65f469d
feat(security): separate scheme identity from credential field name
rian-be Sep 11, 2026
b150128
feat(security): resolve scheme per request and fail closed on invalid…
rian-be Sep 11, 2026
5c2d8d0
fix(build): allow rollForward to latest 10.0 feature band in Docker
rian-be Sep 11, 2026
fe5b1d7
fix(security): resolve IApiKeyValidator lazily to avoid 500s on every…
rian-be Sep 11, 2026
a9d2324
Potential fix for pull request finding 'CodeQL / Log entries created …
rian-be Sep 11, 2026
eeeb388
Potential fix for pull request finding 'CodeQL / Call to 'System.IO.P…
rian-be Sep 11, 2026
2190bc7
fix for finding 'CodeQL / Generic catch clause'
rian-be Sep 11, 2026
ea43c79
Potential fix for pull request finding 'CodeQL / Generic catch clause'
rian-be Sep 11, 2026
ffefa52
Potential fix for pull request finding 'CodeQL / Generic catch clause'
rian-be Sep 11, 2026
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
7 changes: 7 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<GenerateResourceFiles>false</GenerateResourceFiles>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
</Project>
23 changes: 16 additions & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,41 @@
<PackageVersion Include="Google.Protobuf" Version="3.32.0" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageVersion Include="Grpc.Core.Api" Version="2.71.0" />
<PackageVersion Include="Grpc.Net.Client" Version="2.71.0" />
<PackageVersion Include="Grpc.Tools" Version="2.72.0" />
<PackageVersion Include="Marten" Version="8.13.2" />

<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.2.25502.107" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.0-rc.2.25502.107" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />

<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />

<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />

<PackageVersion Include="Scrutor" Version="6.1.0" />

<PackageVersion Include="Swashbuckle.AspNetCore.Annotations" Version="9.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore.Swagger" Version="9.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.6" />
<PackageVersion Include="Swashbuckle.AspNetCore.Annotations" Version="10.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore.Swagger" Version="10.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />

<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />

<PackageVersion Include="WolverineFx" Version="5.0.0" />
<PackageVersion Include="WolverineFx.FluentValidation" Version="5.0.0" />
<PackageVersion Include="WolverineFx.Marten" Version="5.0.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Castle.Core" Version="4.4.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
</Project>
2 changes: 1 addition & 1 deletion Docs/ADR/016-marten-and-wolverine-infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ Plugins and Core share one persistence and messaging model, which keeps handlers
- [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) - keystore stored in Marten
- [ADR-012](./012-token-key-bindings-persisted-in-marten.md) - bindings stored in Marten

[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./015-keycloak-external-jwt-authority.md) | [Next]()
[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./015-keycloak-external-jwt-authority.md) | [Next](./017-api-key-credential-extraction-strategies.md)
59 changes: 59 additions & 0 deletions Docs/ADR/017-api-key-credential-extraction-strategies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./016-marten-and-wolverine-infrastructure.md) | [Next](./018-security-scheme-contract-explicit-handling.md)

# [ADR-017] Extract API Key Credentials Through Location And Body Format Strategies

*2026-09* | Status: accepted

**Tag:** #adr_017

**Date:** 2026-09-10

**Scope:** Host.Security

## Context

Plugins expose authentication mechanisms as transport-agnostic `AuthKitSecuritySchemeDescriptor` metadata (ADR-009). For API key schemes the descriptor declares where the credential lives through `AuthKitApiKeyLocation` (header, query, cookie, gRPC metadata, or request body). The host must turn that metadata into retrieval of an actual credential from an incoming asp.net request, validate it, and establish an authenticated identity without coupling scheme authors to http mechanics.

## Problem

A single extractor/middleware that internally handles all five locations plus body buffering, JSON parsing, form parsing, and value normalization becomes an orchestrator and implementation god object. Adding a new location or a new body format requires modifying existing code, and the low-level transport mechanics (Buffering, JSON, form decoding) are entangled with the orchestration flow (extract -> validate -> identity).

## Decision

API key credential retrieval is split into small, single responsibility strategies registered in the host DI container under `Host.Security`:

- **Location strategies** implement `IApiKeyLocationExtractor`, one per `AuthKitApiKeyLocation` (`Header`, `Query`, `Cookie`, `GrpcMetadata`, `Body`). Each strategy knows only how to read its own location and normalize the value.
- **`IApiKeyLocationExtractorRegistry`** (DI backed) maps `AuthKitApiKeyLocation` -> strategy. The registry throws `NotSupportedException` for locations without registered strategy, so unsupported configurations fail fast.
- **Body format parsers** implement `IApiKeyBodyParser` and are selected by request content type. Today `JsonApiKeyBodyParser` and `FormUrlEncodedApiKeyBodyParser` are registered; a new format is a new class plus registration.
- **`ApiKeyCredentialExtractor`** is a thin middleware that only orchestrates: resolve the scheme, extract via the registry, validate via `IApiKeyValidator`, and on success attach the principal's claims as an authenticated identity. It contains no parsing or buffering logic.
- **`IApiKeyValidator`** stays the plugin facing contract for validating an extracted key, keeping credential semantics out of the host.
- **`ApiKeyCredentialExtractorOptions`** centralizes defaults (header/query/cookie field names, body buffer threshold) and is consumed through `IOptions`.
- All of it is registered by `AddApiKeyCredentialExtraction(...)`.

Body extraction buffers the request through `EnableBuffering`, reads the payload, and restores the stream position so downstream middleware and controllers still see the body intact.

### Design Rationale

- **Open/Closed**: a new location or body format adds class and a DI registration without editing any existing strategy, parser, or the middleware.
- **Single Responsibility**: the middleware owns orchestration; each strategy/parser owns exactly one mechanical concern.
- **Dependency Inversion**: the middleware depends on the registry and validator abstractions, not on `HttpContext` parsing or JSON.
- **Fail open orchestration**: when no credential or no principal is produced, the pipeline continues so downstream authentication decides the outcome extraction never aborts an unrelated request.

## Rejected

- A single switch/if extractor with inline body and JSON logic every new location or format modifies the orchestrator.
- Basing parsing decisions on the scheme inside the middleware couples orchestration to format mechanics.
- Requiring each plugin to implement its own extraction duplicates `HttpContext` parsing, buffering, and normalization across solutions.
- Silent conversion of unsupported locations (eg. treating `GrpcMetadata` as plain HTTP headers without registered strategy) hides misconfiguration.

## Consequences

`Host.Security` now contains many small types instead of one large one adding location or format costs files and registration, but never an edit to existing behavior. The registry makes genuinely unsupported locations fail fast at resolution. Body parsing is content type selective, so ambiguous payloads yield no credential (fail open) rather than an error validation stays plugin concern behind `IApiKeyValidator`, and the host only attaches claims after successful validation.

## Related

- [ADR-009](./009-dynamic-plugin-discovery.md) - descriptors and pluggable authentication contributed by plugins
- [ADR-010](./010-plugin-loading-from-directory.md) - plugin middleware slot where extraction runs
- [ADR-013](./013-dual-rest-and-grpc-transport.md) - gRPC metadata transport represented as lowercase HTTP headers

[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./016-marten-and-wolverine-infrastructure.md) | [Next](./018-security-scheme-contract-explicit-handling.md)
59 changes: 59 additions & 0 deletions Docs/ADR/018-security-scheme-contract-explicit-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./017-api-key-credential-extraction-strategies.md) | [Next](./019-plugin-metadata-attribute.md)

# [ADR-018] Extend The Security Scheme Contract With Explicit Host And OpenAPI Handling

*2026-09* | Status: accepted

**Tag:** #adr_018

**Date:** 2026-09-10

**Scope:** AuthKit.Plugins.Abstractions

## Context

Plugins expose authentication mechanisms as transport agnostic `AuthKitSecuritySchemeDescriptor` metadata (ADR-009). The original contract enum `AuthKitSecuritySchemeType` covered only `ApiKey`, `Http`, `OAuth2`, and `OpenIdConnect` where `OpenIdConnect` was an alias of `OAuth2` sharing numeric value `2`. `AuthKitApiKeyLocation` covered `Header`, `Query`, and `Cookie`. Any other mechanism (mutual TLS, sessions, plugin defined schemes, HTTP Basic) or transport (gRPC metadata, request body) had to be approximated with strings or overloaded existing values.

## Problem

Approximation destroys type safety and lets the host confuse distinct mechanisms: session authentication with an API key carried in a cookie, HTTP Basic with generic HTTP authentication, gRPC metadata with HTTP headers, and body credential with one in a header or query parameter. In addition, the bundled OpenAPI serializer (Swashbuckle 9.x / Microsoft.OpenApi 1.6.x) cannot represent mutual TLS, sessions, custom schemes, gRPC metadata, or body locations in its OpenAPI 3.0 output. Substituting a generic scheme silently would not only hide misconfiguration, it would produce misleading documentation for clients and generated SDKs.

## Decision

Both enums grow additively, and every value is handled explicitly by the host and by the OpenAPI mapper:

- `AuthKitSecuritySchemeType` gains `MutualTls = 3`, `Session = 4`, `Custom = 5`, `Basic = 6`. Existing values stay untouched (`ApiKey = 0`, `Http = 1`, `OAuth2 = 2`). The historical alias is removed: `OpenIdConnect` becomes a distinct value `7` with the deviation documented in its remarks renumbering existing values is forbidden because numeric values are part of the plugin contract (`ApiKey`..`Basic` already occupy `0`..`6`).
- `AuthKitApiKeyLocation` gains `GrpcMetadata = 3` and `Body = 4` existing values stay untouched.
- Numeric values are declared part of the plugin contract in both enums: they must never be reused or renumbered.
- The host enumerates its capabilities as `PluginContractValidator.SupportedSchemeTypes` and `SupportedApiKeyLocations`. `PluginContractValidator.Validate` checks every declared scheme type and location against these sets: supported values pass defined but unsupported and unknown (future) values raise `InvalidPluginContractException`. Unknown values are identified by their numeric identity and never resolved to `Custom` or any known value.
- `AuthKitOpenApiSecuritySchemeMapper` maps every value explicitly: semantically correct OpenAPI representations for `ApiKey`, `Http`, `OAuth2`, `OpenIdConnect`, and `Basic` (as HTTP authentication with scheme `basic`); `NotSupportedException` for values with no correct 3.0 representation (`MutualTls`, `Session`, `Custom`; `GrpcMetadata` and `Body` as API key locations); `ArgumentOutOfRangeException` for unknown values.
- Runtime support and OpenAPI representability are orthogonal: the host supports `GrpcMetadata` and `Body` credential extraction at runtime (ADR-017) even though OpenAPI 3.0 cannot describe them. `RestfulConfiguration` catches mapper failures and logs warning while skipping the definition it never emits a generic substitute and never crashes document generation.
- `Custom` should be accompanied by an optional plugin-supplied `Description`; a missing description produces a validation warning (never a mapping to a built-in scheme).
- The host only accepts `OpenApi:SpecVersion` `3.0`. `3.1` which would be needed for a real `mutualTLS` representation is rejected with `InvalidOperationException` instead of silently emitting 3.0 document.

### Design Rationale

- **Additive contract**: old plugins (including `DevTokens`) compile and load unmodified, and persisted descriptors keep their meaning.
- **Explicit over clever**: every contract value has a named case in exactly two places (host capability check and OpenAPI mapper), forcing authors to decide support or rejection for each new value.
- **Fail fast**: an unsupported scheme fails plugin validation at startup non representable one is omitted from Swagger with a logged reason.
- **No fake OpenAPI**: emitting `apiKey` for mutual TLS or `Header` for gRPC metadata would generate plausible looking but wrong client contracts.

## Rejected

- Renumbering existing enum values to match cleaner sequence breaks every shipped plugin and persisted descriptor forbidden by the must not change rule.
- Keeping the `OpenIdConnect` = `OAuth2` alias two distinct mechanisms cannot share numeric identity a plugin declaring an OIDC flow must not deserialize as OAuth2.
- Generic fallback in the mapper (unknown -> `Custom`, `MutualTls` -> HTTP, `GrpcMetadata` -> `Header`, `Body` -> `Header`/`Query`/`Cookie`) hides misconfiguration and emits misleading documentation.
- Automatically upgrading generated documents to OpenAPI 3.1 to gain `mutualTLS` the bundled serializer stack has no 3.1 support the host rejects the setting rather than silently downgrading.
- Treating `Session` as API-key-in-cookie and `Basic` as generic HTTP authentication reproduces the ambiguity this ADR removes.

## Consequences

Adding new authentication mechanism or transport is now additive: define the value, extend the host capability set, add mapper case, and cover it with positive and positive/negative no fallback tests. The OpenIdConnect value sits outside the natural `0`..`6` sequence (documented in the enum remarks). Plugins that declare unsupported mechanisms fail contract validation at startup per host extensions use `PluginContractValidator.CreateCustom`. Swagger output for plugin mixing representable and non representable schemes contains only the representable definitions, with logged warning per skipped scheme. Two explicit contract guarantees now exist: no silent fallback between schemes or locations, and unknown future values are always rejected.

## Related

- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin security scheme descriptors
- [ADR-017](./017-api-key-credential-extraction-strategies.md) - runtime extraction for header/query/cookie/gRPC metadata/body
- [ADR-013](./013-dual-rest-and-grpc-transport.md) - gRPC transport surface

[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./017-api-key-credential-extraction-strategies.md) | [Next](./019-plugin-metadata-attribute.md)
53 changes: 53 additions & 0 deletions Docs/ADR/019-plugin-metadata-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./018-security-scheme-contract-explicit-handling.md) | [Next](./020-devtools-plugin.md)

# [ADR-019] Declare Plugin Identity Through The PluginMetadata Attribute

*2026-09* | Status: accepted

**Tag:** #adr_019

**Date:** 2026-09-11

**Scope:** AuthKit.Plugins.Abstractions

## Context

Every `IAuthKitPlugin` implementation (ADR-009) exposed its identity by overriding `Name`, `Version`, and `Description` properties in code. The DevTokens and DevTools plugins duplicated that boilerplate, and richer cataloging information stable machine id, tags, capabilities, dependencies, author, license, repository URL had no contract surface at all.

## Problem

Repeating identity as c# code properties spreads the plugin's identity across the class body, makes version bumps code edit, and leaves no single structural place where future tool (loader diagnostics, admin UI) can read identity and cataloging metadata. Adding cataloging metadata would require new members on the contract interface, forcing every existing plugin to implement them.

## Decision

Plugin identity and cataloging metadata move to `[PluginMetadata]` attribute on the plugin class:

- `PluginMetadataAttribute` (namespace `AuthKit.Plugins.Abstractions.Contracts.Plugins`) declares `Id`, `Version`, `Name`, `Tags`, `DependsOn`, `Capabilities`, `DisplayName`, `Description`, `Author`, `License`, `LicenseUrl`, `Homepage`, and `RepositoryUrl`. The constructor is `(id, version, name, ...)` with all fields after `name` optional.
- `IAuthKitPlugin.Name`, `Version`, and `Description` are now **default interface members** that read the attribute through a private helper `GetPluginMetadata()`. Plugins no longer need to implement them: `Name` falls back to the type name and `Version` to `0.0.0` when the class carries no attribute.
- Plugins may still override the three properties if they need computed identity, but the attribute is the preferred declaration point. The two shipped plugins (`DevTokens`, `DevTools`) declare their identity exclusively via `[PluginMetadata]`.
- `AttributeUsage` is `Class`, `Inherited = false`, `AllowMultiple = false` one attribute per plugin class.
- Array metadata (`Tags`, `DependsOn`, `Capabilities`) is exposed as `IReadOnlyList<string>` and defaults to an empty list when absent, so consumers never see `null`.

### Design Rationale

- **Single declaration point**: identity and cataloging live in one attribute, next to the class it describes.
- **Backward compatible**: default interface members keep old plugin classes compilable and loadable no host, plugin, or `PluginLoader` change was required for discovery or startup output.
- **Additive cataloging**: tags/capabilities/dependencies/authoring metadata gains contract surface without touching the interface's member list (no breaking change to existing implementations).
- **Declarative over imperative**: metadata as an attribute is readable, discoverable via reflection, and usable by tools that never instantiate the plugin class.

## Rejected

- Adding `Tags`, `Capabilities`, `Dependencies`, etc. as new abstract members on `IAuthKitPlugin` breaks every existing plugin implementation (compile time contract break).
- Keeping identity purely as code properties side by side with separate cataloging attribute splits identity across two mechanisms.
- A metadata file (JSON/embedded resource) sidecar loses compile time checking and reflection discoverability for little gain.

## Consequences

New plugins declare one attribute and get correct `Name`/`Version`/`Description` for free; the host's plugin loader consumes the attribute through the default interface members, so `ServerHost` startup output ("Loaded plugin 'DevTools' v1.0.0") reflects attribute values. Cataloging fields are available to future administrative surfaces without further contract changes. Because default interface members can be overridden, a plugin that needs computed identity keeps that freedom but the two shipped plugins no longer use it.

## Related

- [ADR-009](./009-dynamic-plugin-discovery.md) - the `IAuthKitPlugin` contract extended by this ADR
- [ADR-010](./010-plugin-loading-from-directory.md) - the loader that reads plugin identity at startup

[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./018-security-scheme-contract-explicit-handling.md) | [Next](./020-devtools-plugin.md)
Loading
Loading