diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9369b4d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + false + + + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 047cab9..093b472 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,32 +8,41 @@ + - - + + - + + - - - - + + + + + + + + + + true + \ No newline at end of file diff --git a/Docs/ADR/016-marten-and-wolverine-infrastructure.md b/Docs/ADR/016-marten-and-wolverine-infrastructure.md index 06aa136..58f524a 100644 --- a/Docs/ADR/016-marten-and-wolverine-infrastructure.md +++ b/Docs/ADR/016-marten-and-wolverine-infrastructure.md @@ -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) diff --git a/Docs/ADR/017-api-key-credential-extraction-strategies.md b/Docs/ADR/017-api-key-credential-extraction-strategies.md new file mode 100644 index 0000000..e8a0b66 --- /dev/null +++ b/Docs/ADR/017-api-key-credential-extraction-strategies.md @@ -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) \ No newline at end of file diff --git a/Docs/ADR/018-security-scheme-contract-explicit-handling.md b/Docs/ADR/018-security-scheme-contract-explicit-handling.md new file mode 100644 index 0000000..1f7a78d --- /dev/null +++ b/Docs/ADR/018-security-scheme-contract-explicit-handling.md @@ -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) \ No newline at end of file diff --git a/Docs/ADR/019-plugin-metadata-attribute.md b/Docs/ADR/019-plugin-metadata-attribute.md new file mode 100644 index 0000000..53d54f9 --- /dev/null +++ b/Docs/ADR/019-plugin-metadata-attribute.md @@ -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` 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) \ No newline at end of file diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..b0e38ab --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/global.json b/global.json index a11f48e..82b35d8 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.0", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "10.0.110", + "rollForward": "latestFeature", + "allowPrerelease": false } } \ No newline at end of file diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 6cdb7dc..072cabf 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Host/Configuration/AppMiddlewareConfiguration.cs b/src/Host/Configuration/AppMiddlewareConfiguration.cs index ad8d632..ab14c50 100644 --- a/src/Host/Configuration/AppMiddlewareConfiguration.cs +++ b/src/Host/Configuration/AppMiddlewareConfiguration.cs @@ -1,5 +1,6 @@ using Host.Plugins; using Host.Restful.Middleware.Exceptions; +using Host.Security.Middleware; namespace Host.Configuration; @@ -10,8 +11,7 @@ namespace Host.Configuration; /// /// /// Configures routing, validation and exception handling, plugin-provided -/// middleware, authentication, authorization, and development-only API -/// documentation middleware. +/// middleware, and authentication and authorization. /// /// /// Plugin middleware is inserted after the host exception handling middleware @@ -44,15 +44,11 @@ public static WebApplication ConfigureMiddleware( app.UseMiddleware(middlewareType); } + app.UseMiddleware(); + app.UseAuthentication(); app.UseAuthorization(); - if (!app.Environment.IsDevelopment()) - return app; - - app.UseSwagger(); - app.UseSwaggerUI(); - return app; } } diff --git a/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs b/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs new file mode 100644 index 0000000..30bc1ba --- /dev/null +++ b/src/Host/Configuration/AuthKitOpenApiSecuritySchemeMapper.cs @@ -0,0 +1,183 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Security.Options; +using Microsoft.OpenApi; + +namespace Host.Configuration; + +/// +/// Maps AuthKit security scheme descriptors to OpenAPI security scheme definitions. +/// +/// +/// +/// Every value is handled explicitly. Values that have a +/// semantically correct OpenAPI representation are mapped to the OpenAPI security scheme type of the +/// document's spec version; values that cannot be represented by the configured OpenAPI version — or +/// whose semantics require host functionality that the current host does not implement — are rejected +/// with a descriptive exception. No value is ever silently mapped to a generic or unrelated security +/// scheme. +/// +/// +/// The current host serializes OpenAPI documents (Swashbuckle 10.2.x / Microsoft.OpenApi 2.7.x). +/// That stack exposes both and OpenAPI 3.1 serialization +/// (SwaggerOptions.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1), so +/// is represented natively — but only in +/// documents pinned to OpenAPI 3.1. Under OpenAPI 3.0 (the host default), mutualTLS has no +/// representation and mutual-TLS schemes are still explicitly rejected rather than rewritten as an +/// HTTP, bearer, or API key scheme. +/// +/// +/// API key locations and +/// are valid credential transports supported by this host at +/// runtime, but no OpenAPI version (3.0 or 3.1) can describe gRPC metadata or body locations. They +/// must never be silently rewritten to header, query, or cookie locations. +/// +/// +/// and +/// and any unknown (future) value are similarly rejected. +/// No generic or unrelated scheme is ever produced as a fallback. +/// +/// +public static class AuthKitOpenApiSecuritySchemeMapper +{ + /// + /// Maps the supplied descriptor to an . + /// + /// The AuthKit security scheme descriptor to map. + /// The OpenAPI spec version of the document being generated. + /// The equivalent OpenAPI security scheme definition. + /// is null. + /// + /// The descriptor declares a scheme type or API key location that has no semantically correct + /// OpenAPI representation in the configured spec version. + /// + /// + /// The descriptor declares an unknown (future) or + /// value. + /// + public static OpenApiSecurityScheme Map( + AuthKitSecuritySchemeDescriptor descriptor, + OpenApiSpecVersion specVersion) + { + ArgumentNullException.ThrowIfNull(descriptor); + + var scheme = new OpenApiSecurityScheme + { + Name = ResolveCredentialName(descriptor), + BearerFormat = descriptor.BearerFormat, + Description = descriptor.Description + }; + + switch (descriptor.Type) + { + case AuthKitSecuritySchemeType.ApiKey: + scheme.Type = SecuritySchemeType.ApiKey; + scheme.In = MapApiKeyLocation(descriptor, specVersion); + break; + + case AuthKitSecuritySchemeType.Http: + scheme.Type = SecuritySchemeType.Http; + scheme.Scheme = descriptor.Scheme; + break; + + case AuthKitSecuritySchemeType.OAuth2: + scheme.Type = SecuritySchemeType.OAuth2; + break; + + case AuthKitSecuritySchemeType.OpenIdConnect: + scheme.Type = SecuritySchemeType.OpenIdConnect; + break; + + case AuthKitSecuritySchemeType.Basic: + scheme.Type = SecuritySchemeType.Http; + scheme.Scheme = "basic"; + break; + + case AuthKitSecuritySchemeType.MutualTls: + if (specVersion == OpenApiSpecVersion.OpenApi3_1) + { + scheme.Type = SecuritySchemeType.MutualTLS; + } + else + { + throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitSecuritySchemeType.MutualTls, " + + $"which cannot be represented in the configured OpenAPI {SpecVersionToLabel(specVersion)} " + + $"document. The current host (Swashbuckle 10.2.x / Microsoft.OpenApi 2.7.x) can serialize " + + $"mutual TLS natively only when the document is pinned to OpenAPI 3.1 " + + $"(OpenApi:SpecVersion = 3.1). Under {SpecVersionToLabel(specVersion)} it must be rejected " + + $"rather than rewritten as an HTTP, bearer, or API key scheme."); + } + break; + + case AuthKitSecuritySchemeType.Session: + throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitSecuritySchemeType.Session, which has no " + + $"semantically correct OpenAPI security scheme representation in any OpenAPI version. " + + $"Session authentication must not be described as an API key, HTTP, or bearer scheme."); + + case AuthKitSecuritySchemeType.Custom: + throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitSecuritySchemeType.Custom, which has no " + + $"built-in OpenAPI security scheme representation. Hosts must register an explicit " + + $"mapping for custom schemes; no default generic mapping is applied."); + + default: + throw new ArgumentOutOfRangeException( + nameof(descriptor), + $"Unknown AuthKitSecuritySchemeType value '{descriptor.Type}' for security scheme " + + $"'{descriptor.Name}' must be rejected rather than mapped to a generic scheme."); + } + + return scheme; + } + + private static ParameterLocation MapApiKeyLocation( + AuthKitSecuritySchemeDescriptor descriptor, + OpenApiSpecVersion specVersion) => + descriptor.In switch + { + AuthKitApiKeyLocation.Header => ParameterLocation.Header, + AuthKitApiKeyLocation.Query => ParameterLocation.Query, + AuthKitApiKeyLocation.Cookie => ParameterLocation.Cookie, + + AuthKitApiKeyLocation.GrpcMetadata => throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitApiKeyLocation.GrpcMetadata, which has no " + + $"OpenAPI representation in version {SpecVersionToLabel(specVersion)}. " + + $"It must not be silently mapped to a header location."), + + AuthKitApiKeyLocation.Body => throw new NotSupportedException( + $"Security scheme '{descriptor.Name}' uses AuthKitApiKeyLocation.Body, which has no " + + $"OpenAPI representation in any OpenAPI version. It must not be silently mapped to a header, " + + $"query, or cookie location."), + + _ => throw new ArgumentOutOfRangeException( + nameof(descriptor), + $"Unknown AuthKitApiKeyLocation value '{descriptor.In}' for security scheme " + + $"'{descriptor.Name}' must be rejected rather than mapped to another location.") + }; + + private static string SpecVersionToLabel(OpenApiSpecVersion specVersion) => + specVersion switch + { + OpenApiSpecVersion.OpenApi2_0 => "2.0 (Swagger)", + OpenApiSpecVersion.OpenApi3_0 => "3.0", + OpenApiSpecVersion.OpenApi3_1 => "3.1", + _ => specVersion.ToString() + }; + + /// + /// Resolves the credential field name for the OpenAPI name property + /// from the descriptor's explicit + /// or the host's location-specific default. + /// + private static string ResolveCredentialName(AuthKitSecuritySchemeDescriptor descriptor) => + descriptor.CredentialName + ?? descriptor.In switch + { + AuthKitApiKeyLocation.Header => ApiKeyCredentialExtractorOptions.DefaultHeaderNameValue, + AuthKitApiKeyLocation.Query => ApiKeyCredentialExtractorOptions.DefaultQueryNameValue, + AuthKitApiKeyLocation.Cookie => ApiKeyCredentialExtractorOptions.DefaultCookieNameValue, + _ => descriptor.Name + }; +} diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs index d0bea77..f9e6b95 100644 --- a/src/Host/Configuration/RestfulConfiguration.cs +++ b/src/Host/Configuration/RestfulConfiguration.cs @@ -1,7 +1,9 @@ using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; using Host.Plugins; -using Microsoft.OpenApi.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.OpenApi; namespace Host.Configuration; @@ -28,6 +30,8 @@ public static class RestfulConfiguration /// The plugins loaded by the Host. Each plugin may contribute one or more /// OpenAPI security scheme definitions. /// + /// The host application configuration. + /// The logger used to report skipped security schemes. /// The configured instance. /// /// The method registers API explorer support and creates Swagger document named v1. @@ -37,9 +41,25 @@ public static class RestfulConfiguration /// added to the generated OpenAPI document together with a corresponding /// security requirement. /// + /// + /// Security schemes without a semantically correct OpenAPI representation are + /// explicitly rejected by and + /// skipped (with a logged warning) rather than being silently mapped to a + /// generic scheme. + /// + /// + /// The OpenApi:SpecVersion configuration value is not supported by + /// this host. Supported values are 3.0 (default) and 3.1. + /// /// - public static IServiceCollection AddRestfulServices(this IServiceCollection services, IReadOnlyList plugins) + public static IServiceCollection AddRestfulServices( + this IServiceCollection services, + IReadOnlyList plugins, + IConfiguration configuration, + ILogger logger) { + var specVersion = ResolveOpenApiSpecVersion(configuration); + services.AddEndpointsApiExplorer(); services.AddSwaggerGen(c => @@ -57,14 +77,11 @@ public static IServiceCollection AddRestfulServices(this IServiceCollection serv Description = "Insert JWT token in the format: Bearer {token}" }); - c.AddSecurityRequirement(new OpenApiSecurityRequirement + c.AddSecurityRequirement(document => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } - }, - Array.Empty() + new OpenApiSecuritySchemeReference("Bearer", document), + new List() } }); @@ -72,15 +89,32 @@ public static IServiceCollection AddRestfulServices(this IServiceCollection serv { foreach (var (name, descriptor) in lp.Plugin.GetSecuritySchemes()) { - c.AddSecurityDefinition(name, ToOpenApiSecurityScheme(descriptor)); - c.AddSecurityRequirement(new OpenApiSecurityRequirement + OpenApiSecurityScheme openApiScheme; + try + { + openApiScheme = AuthKitOpenApiSecuritySchemeMapper.Map(descriptor, specVersion); + } + catch (NotSupportedException ex) + { + logger.LogWarning( + "Skipping security scheme '{Scheme}' contributed by plugin '{Plugin}': {Reason}", + name, lp.Plugin.Name, ex.Message); + continue; + } + catch (ArgumentOutOfRangeException ex) + { + logger.LogWarning( + "Skipping security scheme '{Scheme}' contributed by plugin '{Plugin}' because it declares an unknown contract value: {Reason}", + name, lp.Plugin.Name, ex.Message); + continue; + } + + c.AddSecurityDefinition(name, openApiScheme); + c.AddSecurityRequirement(document => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = name } - }, - Array.Empty() + new OpenApiSecuritySchemeReference(name, document), + new List() } }); } @@ -90,27 +124,24 @@ public static IServiceCollection AddRestfulServices(this IServiceCollection serv return services; } - private static OpenApiSecurityScheme ToOpenApiSecurityScheme(AuthKitSecuritySchemeDescriptor descriptor) => - new() + internal static OpenApiSpecVersion ResolveOpenApiSpecVersion(IConfiguration configuration) + { + var configured = configuration["OpenApi:SpecVersion"]?.Trim() ?? "3.0"; + + if (configured.Equals("3.0", StringComparison.OrdinalIgnoreCase) + || configured.StartsWith("3.0.", StringComparison.OrdinalIgnoreCase)) { - Name = descriptor.Name, - Type = descriptor.Type switch - { - AuthKitSecuritySchemeType.ApiKey => SecuritySchemeType.ApiKey, - AuthKitSecuritySchemeType.Http => SecuritySchemeType.Http, - AuthKitSecuritySchemeType.OAuth2 => SecuritySchemeType.OAuth2, - AuthKitSecuritySchemeType.OpenIdConnect => SecuritySchemeType.OpenIdConnect, - _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) - }, - In = descriptor.In switch - { - AuthKitApiKeyLocation.Header => ParameterLocation.Header, - AuthKitApiKeyLocation.Query => ParameterLocation.Query, - AuthKitApiKeyLocation.Cookie => ParameterLocation.Cookie, - _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) - }, - Scheme = descriptor.Scheme, - BearerFormat = descriptor.BearerFormat, - Description = descriptor.Description - }; -} + return OpenApiSpecVersion.OpenApi3_0; + } + + if (configured.Equals("3.1", StringComparison.OrdinalIgnoreCase) + || configured.StartsWith("3.1.", StringComparison.OrdinalIgnoreCase)) + { + return OpenApiSpecVersion.OpenApi3_1; + } + + throw new InvalidOperationException( + $"The configured OpenAPI spec version '{configured}' (OpenApi:SpecVersion) is not supported by this host. " + + $"Set 'OpenApi:SpecVersion' to '3.0' (default) or '3.1'."); + } +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginContractValidator.cs b/src/Host/Plugins/PluginContractValidator.cs new file mode 100644 index 0000000..72c74d6 --- /dev/null +++ b/src/Host/Plugins/PluginContractValidator.cs @@ -0,0 +1,190 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Logging; + +namespace Host.Plugins; + +/// +/// Validates AuthKit plugin contracts against the host's supported capabilities. +/// +/// +/// Ensures plugins declare only supported security scheme types and API key locations. +/// Unknown or unsupported values cause explicit validation failures rather than silent fallbacks. +/// +public static class PluginContractValidator +{ + /// + /// Security scheme types the host implements and can therefore accept from plugins. + /// + /// + /// Mutual TLS, session, custom, and HTTP Basic authentication are not implemented by + /// this host. They are recognized explicitly and rejected rather than silently accepted + /// as if they were supported. Hosts that implement them extend the supported set through + /// . + /// + private static readonly HashSet _supportedSchemeTypes = + [ + AuthKitSecuritySchemeType.ApiKey, + AuthKitSecuritySchemeType.Http, + AuthKitSecuritySchemeType.OAuth2, + AuthKitSecuritySchemeType.OpenIdConnect + ]; + + /// + /// Credential locations the host's credential extraction strategies implement. + /// + /// + /// The host ships location extractor strategies for every value, including + /// and + /// . Note that gRPC metadata and body locations are + /// still rejected by the OpenAPI mapper (AuthKitOpenApiSecuritySchemeMapper) because + /// OpenAPI 3.0 cannot represent them — runtime support and OpenAPI representability are + /// validated independently. + /// + private static readonly HashSet _supportedApiKeyLocations = + [ + AuthKitApiKeyLocation.Header, + AuthKitApiKeyLocation.Query, + AuthKitApiKeyLocation.Cookie, + AuthKitApiKeyLocation.GrpcMetadata, + AuthKitApiKeyLocation.Body + ]; + + public static IReadOnlySet SupportedSchemeTypes => _supportedSchemeTypes; + public static IReadOnlySet SupportedApiKeyLocations => _supportedApiKeyLocations; + + /// + /// Validates a plugin's security scheme descriptors against host capabilities. + /// + /// The plugin to validate. + /// Logger for validation results. + /// + /// Thrown when the plugin declares unsupported or unknown scheme types/locations. + /// + public static void Validate(IAuthKitPlugin plugin, ILogger logger) + { + var schemes = plugin.GetSecuritySchemes(); + + foreach (var (name, descriptor) in schemes) + { + ValidateSchemeType(name, descriptor.Type, SupportedSchemeTypes, logger); + ValidateApiKeyLocation(name, descriptor.In, SupportedApiKeyLocations, logger); + WarnIfCustomWithoutUsageDocumentation(name, descriptor, SupportedSchemeTypes, logger); + } + } + + internal static void ValidateSchemeType( + string schemeName, + AuthKitSecuritySchemeType type, + IReadOnlySet supportedTypes, + ILogger logger) + { + if (supportedTypes.Contains(type)) + return; + + var msg = Enum.IsDefined(type) + ? $"Plugin declares security scheme type '{type}' for security scheme '{schemeName}', which is not implemented by this host. " + + $"MutualTls, Session, Custom, and Basic must be explicitly rejected. Supported types: {string.Join(", ", supportedTypes)}." + : $"Plugin declares unknown AuthKitSecuritySchemeType value '{(int)type}' for security scheme '{schemeName}'. " + + $"Unknown future values must be rejected rather than treated as a known type."; + + logger.LogError(msg); + throw new InvalidPluginContractException(msg); + } + + /// + /// Warns when a plugin declares a scheme without + /// usage documentation. + /// + /// + /// The warning is only emitted for hosts that actually support custom schemes (the default host + /// rejects them with an explicit error). It must never cause the custom scheme to be mapped to + /// another security scheme. + /// + internal static void WarnIfCustomWithoutUsageDocumentation( + string schemeName, + AuthKitSecuritySchemeDescriptor descriptor, + IReadOnlySet supportedTypes, + ILogger logger) + { + if (descriptor.Type != AuthKitSecuritySchemeType.Custom || !supportedTypes.Contains(descriptor.Type)) + return; + + if (string.IsNullOrWhiteSpace(descriptor.Description)) + { + logger.LogWarning( + "Security scheme '{Scheme}' declares AuthKitSecuritySchemeType.Custom but provides no description " + + "explaining how the custom authentication mechanism is intended to be used. Add a Description to the " + + "AuthKitSecuritySchemeDescriptor so clients and operators understand the mechanism. " + + "Custom schemes are never mapped to a built-in security scheme.", + schemeName); + } + } + + internal static void ValidateApiKeyLocation( + string schemeName, + AuthKitApiKeyLocation location, + IReadOnlySet supportedLocations, + ILogger logger) + { + if (supportedLocations.Contains(location)) + return; + + var msg = Enum.IsDefined(location) + ? $"Plugin declares API key location '{location}' for security scheme '{schemeName}', which is not supported by this host's " + + $"credential extraction strategies. Configure a host with {location} support or change the plugin configuration." + : $"Plugin declares unknown AuthKitApiKeyLocation value '{(int)location}' for security scheme '{schemeName}'. " + + $"Unknown future values must be rejected rather than treated as a known location."; + + logger.LogError(msg); + throw new InvalidPluginContractException(msg); + } + + /// + /// Creates a validator with custom supported locations (for hosts with extended capabilities). + /// + public static PluginContractValidatorCustom CreateCustom( + IEnumerable? supportedSchemeTypes = null, + IEnumerable? supportedApiKeyLocations = null) + { + return new PluginContractValidatorCustom(supportedSchemeTypes, supportedApiKeyLocations); + } +} + +/// +/// Customizable validator for hosts with extended capabilities (e.g., gRPC metadata, Body support). +/// +public sealed class PluginContractValidatorCustom +{ + private readonly HashSet _supportedSchemeTypes; + private readonly HashSet _supportedApiKeyLocations; + + public PluginContractValidatorCustom( + IEnumerable? supportedSchemeTypes = null, + IEnumerable? supportedApiKeyLocations = null) + { + _supportedSchemeTypes = new HashSet(supportedSchemeTypes ?? PluginContractValidator.SupportedSchemeTypes); + _supportedApiKeyLocations = new HashSet(supportedApiKeyLocations ?? PluginContractValidator.SupportedApiKeyLocations); + } + + public void Validate(IAuthKitPlugin plugin, ILogger logger) + { + var schemes = plugin.GetSecuritySchemes(); + + foreach (var (name, descriptor) in schemes) + { + PluginContractValidator.ValidateSchemeType(name, descriptor.Type, _supportedSchemeTypes, logger); + PluginContractValidator.ValidateApiKeyLocation(name, descriptor.In, _supportedApiKeyLocations, logger); + PluginContractValidator.WarnIfCustomWithoutUsageDocumentation(name, descriptor, _supportedSchemeTypes, logger); + } + } +} + +/// +/// Exception thrown when a plugin contract violates host capabilities. +/// +public sealed class InvalidPluginContractException : Exception +{ + public InvalidPluginContractException(string message) : base(message) { } +} \ No newline at end of file diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs index 858dbc2..cc37605 100644 --- a/src/Host/Plugins/PluginLoader.cs +++ b/src/Host/Plugins/PluginLoader.cs @@ -1,12 +1,14 @@ using System.Reflection; using System.Runtime.Loader; +using AuthKit.Plugins.Abstractions; using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Models; +using Microsoft.Extensions.Logging; namespace Host.Plugins; /// -/// Discovers and loads AuthKit plugins from a specified directory during host startup. +/// Discovers and loads AuthKit plugins from specified directory during host startup. /// /// /// @@ -30,17 +32,38 @@ public static class PluginLoader /// /// Discovers and loads all valid AuthKit plugins from the specified root directory. /// - /// The root directory containing one subdirectory per plugin. - /// The logger used to report plugin discovery, loading, and validation results. - /// The version of the host application. + /// The root directory containing one subdirectory per plugin. + /// The logger used to report plugin discovery, loading, and validation results. + /// The version of the host application, used to reject plugins that + /// require a newer host. /// A readonly collection containing all successfully loaded plugins. /// /// Each plugin directory is expected to contain an entry assembly whose file name /// matches the directory name. Directories without matching assembly or assemblies /// without valid implementation are skipped. - /// Plugins requiring a higher host version than are rejected. /// - public static IReadOnlyList LoadPlugins(string pluginsRootPath, ILogger logger, SemanticVersion hostVersion) + public static IReadOnlyList LoadPlugins( + string pluginsRootPath, + ILogger logger, + SemanticVersion hostVersion) => + LoadPluginsCore(pluginsRootPath, logger, hostVersion); + + /// + /// Discovers and loads all valid AuthKit plugins from the specified root directory. + /// + /// The root directory containing one subdirectory per plugin. + /// The logger used to report plugin discovery, loading, and validation results. + /// A readonly collection containing all successfully loaded plugins. + [Obsolete("Use the overload taking a host version to enforce plugin MinHostVersion compatibility.")] + public static IReadOnlyList LoadPlugins( + string pluginsRootPath, + ILogger logger) => + LoadPluginsCore(pluginsRootPath, logger, hostVersion: null); + + private static IReadOnlyList LoadPluginsCore( + string pluginsRootPath, + ILogger logger, + SemanticVersion? hostVersion) { if (!Directory.Exists(pluginsRootPath)) { @@ -52,186 +75,92 @@ public static IReadOnlyList LoadPlugins(string pluginsRootPath, IL foreach (var pluginDir in Directory.GetDirectories(pluginsRootPath)) { - if (TryLoadPlugin(pluginDir, logger, hostVersion, loaded, out var plugin)) + var pluginName = Path.GetFileName(pluginDir); + var entryDllPath = Path.Join(pluginDir, $"{pluginName}.dll"); + + if (!File.Exists(entryDllPath)) { - loaded.Add(plugin); - logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Plugin.Name, plugin.Plugin.Version.ToString(), pluginDir); + logger.LogError( + "Skipping plugin folder '{Dir}': expected entry assembly '{Dll}' not found.", + pluginDir, entryDllPath); + continue; } - } - return loaded; - } + try + { + var resolver = new AssemblyDependencyResolver(entryDllPath); + AssemblyLoadContext.Default.Resolving += (context, name) => + { + var path = resolver.ResolveAssemblyToPath(name); + return path is not null ? context.LoadFromAssemblyPath(path) : null; + }; - private static bool TryLoadPlugin( - string pluginDir, - ILogger logger, - SemanticVersion hostVersion, - IReadOnlyCollection alreadyLoaded, - out LoadedPlugin loadedPlugin) - { - loadedPlugin = null!; + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(entryDllPath); - var pluginName = Path.GetFileName(pluginDir); - var entryDllPath = Path.Combine(pluginDir, $"{pluginName}.dll"); + var pluginType = assembly.GetTypes() + .FirstOrDefault(t => t is { IsPublic: true, IsAbstract: false } + && typeof(IAuthKitPlugin).IsAssignableFrom(t) + && t.GetConstructor(Type.EmptyTypes) is not null); - if (!File.Exists(entryDllPath)) - { - logger.LogError( - "Skipping plugin folder '{Dir}': expected entry assembly '{Dll}' not found.", - pluginDir, entryDllPath); - return false; - } + if (pluginType is null) + { + logger.LogError( + "Skipping plugin assembly '{Dll}': no public, non-abstract IAuthKitPlugin implementation with a parameterless constructor found.", + entryDllPath); + continue; + } - try - { - var manifest = TryReadManifest(pluginDir, pluginName, logger); + var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; - if (manifest is not null && !ManifestIsUsable(manifest, pluginDir, alreadyLoaded, logger)) - return false; + if (hostVersion is { } hv && plugin.MinHostVersion is { } minVersion && hv < minVersion) + { + logger.LogError( + "Skipping plugin '{Dir}': host version {HostVersion} is lower than required minimum {MinVersion}.", + pluginDir, hv, minVersion); + continue; + } + + // Validate plugin contract against host capabilities + PluginContractValidator.Validate(plugin, logger); - var assembly = LoadAssembly(entryDllPath); - var pluginType = FindPluginType(assembly); + loaded.Add(new LoadedPlugin(plugin, assembly, pluginDir)); - if (pluginType is null) + logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Name, plugin.Version, pluginDir); + } + catch (FileNotFoundException ex) { - logger.LogError( - "Skipping plugin assembly '{Dll}': no public, non-abstract IAuthKitPlugin implementation with a parameterless constructor found.", - entryDllPath); - return false; + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); } - - var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; - - if (!string.IsNullOrWhiteSpace(plugin.Id) && - alreadyLoaded.Any(lp => string.Equals(lp.Plugin.Id, plugin.Id, StringComparison.Ordinal))) + catch (FileLoadException ex) { - logger.LogError("Skipping plugin '{Dir}': duplicate plugin Id '{Id}' detected.", pluginDir, plugin.Id); - return false; + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); } - - // Without manifest, MinHostVersion (if declared on the instance) is the only - // compatibility check available. With a manifest, this is covered by ValidateConsistency below. - if (manifest is null && plugin.MinHostVersion is not null && hostVersion < plugin.MinHostVersion) + catch (BadImageFormatException ex) { - logger.LogError( - "Skipping plugin '{Dir}': host version {HostVersion} is lower than required minimum {MinVersion}.", - pluginDir, hostVersion.ToString(), plugin.MinHostVersion.ToString()); - return false; + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); } - - if (manifest is not null) + catch (ReflectionTypeLoadException ex) { - try - { - PluginValidator.ValidateConsistency(manifest, plugin); - } - catch (Exception ex) - { - logger.LogError(ex, "Skipping plugin '{Dir}': manifest/instance consistency check failed.", pluginDir); - return false; - } + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (TypeLoadException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (MissingMethodException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (TargetInvocationException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + } + catch (InvalidCastException ex) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); } - - loadedPlugin = new LoadedPlugin(plugin, assembly, pluginDir); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - return false; - } - } - - private static PluginManifest? TryReadManifest(string pluginDir, string pluginName, ILogger logger) - { - string[] manifestCandidates = - [ - Path.Combine(pluginDir, "manifest.json"), - Path.Combine(pluginDir, $"{pluginName}.manifest.json") - ]; - - var manifestPath = manifestCandidates.FirstOrDefault(File.Exists); - if (manifestPath is null) - return null; - - try - { - var json = File.ReadAllText(manifestPath); - return System.Text.Json.JsonSerializer.Deserialize( - json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to parse manifest {Manifest} for plugin {Dir}", manifestPath, pluginDir); - return null; - } - } - - private static bool ManifestIsUsable( - PluginManifest manifest, - string pluginDir, - IReadOnlyCollection alreadyLoaded, - ILogger logger) - { - try - { - PluginValidator.ValidateTags(manifest.Tags); - } - catch (Exception ex) - { - logger.LogError(ex, "Skipping plugin '{Dir}': invalid tags in manifest.", pluginDir); - return false; - } - - try - { - PluginValidator.ValidateDependsOn(manifest.DependsOn, manifest.Id); - } - catch (Exception ex) - { - logger.LogError(ex, "Skipping plugin '{Dir}': invalid dependencies in manifest.", pluginDir); - return false; - } - - if (!manifest.IsEnabled) - { - logger.LogInformation("Skipping plugin '{Dir}': manifest indicates IsEnabled=false.", pluginDir); - return false; - } - - if (!string.IsNullOrWhiteSpace(manifest.Id) && - alreadyLoaded.Any(lp => string.Equals(lp.Plugin.Id, manifest.Id, StringComparison.Ordinal))) - { - logger.LogError("Skipping plugin '{Dir}': duplicate plugin Id '{Id}' detected in manifest.", pluginDir, manifest.Id); - return false; } - return true; - } - - private static Assembly LoadAssembly(string entryDllPath) - { - var resolver = new AssemblyDependencyResolver(entryDllPath); - Func handler = (context, name) => - { - var path = resolver.ResolveAssemblyToPath(name); - return path is not null ? context.LoadFromAssemblyPath(path) : null; - }; - - AssemblyLoadContext.Default.Resolving += handler; - try - { - return AssemblyLoadContext.Default.LoadFromAssemblyPath(entryDllPath); - } - finally - { - AssemblyLoadContext.Default.Resolving -= handler; - } + return loaded; } - - private static Type? FindPluginType(Assembly assembly) => - assembly.GetTypes().FirstOrDefault(t => - t is { IsPublic: true, IsAbstract: false } && - typeof(IAuthKitPlugin).IsAssignableFrom(t) && - t.GetConstructor(Type.EmptyTypes) is not null); -} \ No newline at end of file +} diff --git a/src/Host/Program.cs b/src/Host/Program.cs index d20eec8..8f21772 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -1,6 +1,7 @@ using Host.Configuration; using Host.Plugins; using Host.Cli; +using Host.Security; using AuthKit.Plugins.Abstractions; using System.Reflection; using AuthKit.Plugins.Abstractions.Models; @@ -18,13 +19,16 @@ var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger, hostVersion); +var restfulLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("RestfulConfiguration"); + // === Core Config === builder.Services.AddSingleton(plugins); builder.Services.AddAuthKitCore(); builder.Services.ConfigureApp(builder.Configuration, plugins) .AddGrpcServices() - .AddRestfulServices(plugins) + .AddRestfulServices(plugins, builder.Configuration, restfulLogger) + .AddApiKeyCredentialExtraction() .AddKeycloakServices(); foreach (var lp in plugins) diff --git a/src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs b/src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs new file mode 100644 index 0000000..b4100c3 --- /dev/null +++ b/src/Host/Security/BodyParsers/FormUrlEncodedApiKeyBodyParser.cs @@ -0,0 +1,69 @@ +namespace Host.Security.BodyParsers; + +/// +/// Extracts an API key field from an +/// application/x-www-form-urlencoded request body. +/// +/// +/// Form fields are matched by name using case-insensitive comparison. +/// +/// Values are decoded according to standard URL form encoding rules, +/// including restoring + characters as spaces. +/// +/// +public sealed class FormUrlEncodedApiKeyBodyParser : IApiKeyBodyParser +{ + /// + /// Determines whether this parser supports the specified content type. + /// + /// The request content type. + /// + /// true if the content type is + /// application/x-www-form-urlencoded; otherwise, false. + /// + public bool CanHandle(string? contentType) => + !string.IsNullOrEmpty(contentType) + && contentType.Contains( + "application/x-www-form-urlencoded", + StringComparison.OrdinalIgnoreCase); + + /// + /// Extracts an API key from the specified form field. + /// + /// The URL encoded form request body. + /// The name of the field containing the API key. + /// + /// The decoded API key, or null if the specified field is not present. + /// + public string? Parse(string body, string fieldName) + { + foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separatorIndex = pair.IndexOf('='); + + if (separatorIndex <= 0) + continue; + + var name = FormDecode(pair[..separatorIndex]); + + if (!string.Equals(name, fieldName, StringComparison.OrdinalIgnoreCase)) + continue; + + var value = pair[(separatorIndex + 1)..]; + + return string.IsNullOrEmpty(value) + ? value + : FormDecode(value); + } + + return null; + } + + /// + /// Decodes URL encoded form value, restoring + characters as spaces. + /// + /// The encoded form value. + /// The decoded form value. + private static string FormDecode(string value) => + Uri.UnescapeDataString(value.Replace('+', ' ')); +} \ No newline at end of file diff --git a/src/Host/Security/BodyParsers/IApiKeyBodyParser.cs b/src/Host/Security/BodyParsers/IApiKeyBodyParser.cs new file mode 100644 index 0000000..212ca78 --- /dev/null +++ b/src/Host/Security/BodyParsers/IApiKeyBodyParser.cs @@ -0,0 +1,37 @@ +namespace Host.Security.BodyParsers; + +/// +/// Extracts an API key field from a request body payload of a specific format. +/// +/// +/// +/// Implementations are selected by content type, allowing new request body +/// formats to be supported without modifying existing code. +/// +/// +/// The parser is expected to return null when the requested field is +/// absent or the payload cannot be parsed. +/// +/// +public interface IApiKeyBodyParser +{ + /// + /// Determines whether this parser can handle the given content type. + /// + /// The request content type value. + /// + /// true when the parser can handle the content type; otherwise, + /// false. + /// + bool CanHandle(string? contentType); + + /// + /// Extracts the value of the named field from the request body. + /// + /// The raw request body payload. + /// The name of the field holding the API key. + /// + /// The extracted API key, or null when the field is absent. + /// + string? Parse(string body, string fieldName); +} \ No newline at end of file diff --git a/src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs b/src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs new file mode 100644 index 0000000..24e9f07 --- /dev/null +++ b/src/Host/Security/BodyParsers/JsonApiKeyBodyParser.cs @@ -0,0 +1,71 @@ +using System.Text.Json; + +namespace Host.Security.BodyParsers; + +/// +/// Extracts an API key field from a JSON request body. +/// +/// +/// +/// Field names are matched using case-insensitive comparison, and only +/// string-valued properties are considered. +/// +/// Invalid JSON payloads are ignored and result in null. +/// +public sealed class JsonApiKeyBodyParser( + ILogger logger) : IApiKeyBodyParser +{ + /// + /// Determines whether this parser supports the specified content type. + /// + /// The request content type. + /// true if the content type is application/json otherwise, false + public bool CanHandle(string? contentType) => + !string.IsNullOrEmpty(contentType) + && contentType.Contains( + "application/json", + StringComparison.OrdinalIgnoreCase); + + /// + /// Extracts an API key from the specified JSON field. + /// + /// The JSON request body. + /// The name of the field containing the API key. + /// + /// The API key value, or null if the field is not found or + /// the request body contains invalid or unsupported JSON. + /// + public string? Parse(string body, string fieldName) + { + if (!body.AsSpan().TrimStart().StartsWith('{')) + return null; + + try + { + using var doc = JsonDocument.Parse(body); + + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return null; + + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (property.Value.ValueKind == JsonValueKind.String + && string.Equals( + property.Name, + fieldName, + StringComparison.OrdinalIgnoreCase)) + { + return property.Value.GetString(); + } + } + } + catch (JsonException ex) + { + logger.LogDebug( + ex, + "Request body is not valid JSON; skipping JSON parsing"); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Host/Security/ISecuritySchemeRegistry.cs b/src/Host/Security/ISecuritySchemeRegistry.cs new file mode 100644 index 0000000..4606fc3 --- /dev/null +++ b/src/Host/Security/ISecuritySchemeRegistry.cs @@ -0,0 +1,32 @@ +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; + +namespace Host.Security; + +/// +/// Resolves the that protects the +/// current request. +/// +/// +/// +/// The registry is built from the security schemes contributed by every enabled +/// plugin at startup. It provides request-aware lookup by scheme name, replacing +/// any reliance on a single ambient descriptor registered in the container. +/// +/// +/// Lookups are case-insensitive because scheme names are identifiers referenced +/// by endpoint metadata and are expected to be treated consistently across the +/// host and plugins. +/// +/// +public interface ISecuritySchemeRegistry +{ + /// + /// Looks up the descriptor for the named security scheme. + /// + /// The scheme name declared on the endpoint. + /// + /// Receives the matching descriptor when found; otherwise the null reference. + /// + /// true when the scheme is registered; otherwise, false. + bool TryGet(string schemeName, out AuthKitSecuritySchemeDescriptor result); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs new file mode 100644 index 0000000..2369e88 --- /dev/null +++ b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorBase.cs @@ -0,0 +1,64 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Base class for implementations that +/// resolve credential names using configured defaults. +/// +/// +/// +/// Derived classes declare the credential location they handle through +/// and implement credential extraction in +/// . +/// +/// +/// uses the configured default when the security +/// scheme does not provide an explicit credential name. +/// +/// +public abstract class ApiKeyLocationExtractorBase : IApiKeyLocationExtractor +{ + /// + /// Gets the options controlling credential extraction defaults and + /// request body buffering behavior. + /// + protected ApiKeyCredentialExtractorOptions Options { get; } + + /// + /// Initializes new instance of the + /// class. + /// + /// The options controlling credential extraction. + protected ApiKeyLocationExtractorBase( + IOptions options) => + Options = options.Value; + + /// + /// Gets the API key location handled by this extractor. + /// + public abstract AuthKitApiKeyLocation Location { get; } + + /// + /// Extracts an API key from the specified HTTP request. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// The extracted API key, or null if the credential is not present. + public abstract Task ExtractAsync(HttpContext context, AuthKitSecuritySchemeDescriptor scheme); + + /// + /// Resolves the credential name using the configured or provided default. + /// + /// The credential field name declared by the scheme. + /// The default name used when the scheme is empty. + /// The resolved credential name. + protected static string ResolveName(string? credentialName, string defaultValue) => + string.IsNullOrWhiteSpace(credentialName) + ? defaultValue + : credentialName; +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs new file mode 100644 index 0000000..a93dff4 --- /dev/null +++ b/src/Host/Security/LocationExtractors/ApiKeyLocationExtractorRegistry.cs @@ -0,0 +1,38 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.LocationExtractors; + +/// +/// Resolves API key location extractors registered in the dependency injection container. +/// +/// +/// +/// Extractors are indexed by their declared . +/// Registering more than one extractor for the same location causes an exception. +/// +/// +/// Additional locations can be supported by implementing +/// and registering the implementation +/// in the dependency injection container. +/// +/// +public sealed class ApiKeyLocationExtractorRegistry( + IEnumerable extractors) : IApiKeyLocationExtractorRegistry +{ + private readonly IReadOnlyDictionary _extractors = + extractors.ToDictionary(e => e.Location); + + /// + /// Resolves the extractor registered for the specified API key location. + /// + /// The API key credential location. + /// The extractor responsible for the specified location. + /// + /// Thrown when no extractor is registered for the specified location. + /// + public IApiKeyLocationExtractor Resolve(AuthKitApiKeyLocation location) => + _extractors.TryGetValue(location, out var extractor) + ? extractor + : throw new NotSupportedException( + $"No API key credential extractor is registered for location '{location}'. Configure the host to support this location."); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs b/src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs new file mode 100644 index 0000000..6e2ab3a --- /dev/null +++ b/src/Host/Security/LocationExtractors/ApiKeyValueNormalizer.cs @@ -0,0 +1,44 @@ +namespace Host.Security.LocationExtractors; + +/// +/// Normalizes API key credential values extracted from HTTP requests. +/// +/// +/// +/// Credential values may contain surrounding whitespace or quotes introduced +/// by the transport. The normalizer removes these characters before validation. +/// +/// +/// additionally removes case-insensitive +/// Bearer prefix from the normalized value. +/// +/// +public static class ApiKeyValueNormalizer +{ + private const string BearerPrefix = "Bearer "; + + /// + /// Trims surrounding whitespace and wrapping double quotes. + /// + /// The raw credential value. + /// The normalized credential value. + public static string Normalize(string value) => + value.Trim().Trim('"'); + + /// + /// Normalizes the credential value and removes Bearer prefix, + /// if present. + /// + /// The raw credential value. + /// The normalized credential value without the bearer prefix. + public static string StripBearerPrefix(string value) + { + var normalized = Normalize(value); + + return normalized.StartsWith( + BearerPrefix, + StringComparison.OrdinalIgnoreCase) + ? normalized[BearerPrefix.Length..].Trim() + : normalized; + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs new file mode 100644 index 0000000..ae19333 --- /dev/null +++ b/src/Host/Security/LocationExtractors/BodyApiKeyLocationExtractor.cs @@ -0,0 +1,172 @@ +using System.Text; +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Options; +using Host.Security.BodyParsers; +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from buffered request body using registered +/// that supports the request content type. +/// +/// +/// +/// The request body is buffered to allow reading and rewinding without +/// affecting downstream middleware or request handlers. +/// +/// +/// controls how much of the +/// body is buffered in memory before spilling to a temporary file. It is not a size limit: +/// bodies larger than the threshold continue to be read. +/// +/// +/// is the optional hard limit on +/// how much of the request body is read for credential extraction. When set, bodies larger +/// than the limit are rejected instead of being read in full. Requests without a known +/// Content-Length are read through the same bounded path when +/// is configured. +/// +/// +public sealed class BodyApiKeyLocationExtractor( + IEnumerable parsers, + IOptions options, + ILogger logger) : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Body; + + /// + /// Extracts an API key from the request body using a parser that supports + /// the request content type. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// + /// The extracted API key, or null when the body cannot be processed + /// or does not contain the configured credential field. + /// + public override async Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + if (context.Request.ContentLength == 0) + return null; + + var parser = parsers.FirstOrDefault( + p => p.CanHandle(context.Request.ContentType)); + + if (parser is null) + { + var sanitizedContentType = (context.Request.ContentType ?? string.Empty) + .Replace("\r", string.Empty) + .Replace("\n", string.Empty); + + logger.LogDebug("Skipping Body credential extraction for scheme {Scheme}: unsupported content type {ContentType}", + scheme.Name, + sanitizedContentType); + + return null; + } + + if (context.Request.ContentLength is { } length + && Options.MaxBodySize > 0 + && length > Options.MaxBodySize) + { + logger.LogWarning( + "Skipping Body credential extraction for scheme {Scheme}: body of {Length} bytes exceeds configured MaxBodySize of {MaxBodySize} bytes", + scheme.Name, + length, + Options.MaxBodySize); + + return null; + } + + if (!context.Request.Body.CanSeek) + context.Request.EnableBuffering(Options.BufferThreshold); + + var originalPosition = context.Request.Body.Position; + + try + { + context.Request.Body.Position = 0; + + string? body; + + if (Options.MaxBodySize > 0) + { + body = await ReadBoundedBodyAsync(context.Request.Body, Options.MaxBodySize); + } + else + { + using var reader = new StreamReader( + context.Request.Body, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: true); + + body = await reader.ReadToEndAsync(); + } + + if (body is null) + { + logger.LogWarning( + "Skipping Body credential extraction for scheme {Scheme}: body exceeds configured MaxBodySize of {MaxBodySize} bytes", + scheme.Name, + Options.MaxBodySize); + + return null; + } + + if (string.IsNullOrWhiteSpace(body)) + return null; + + var fieldName = ResolveName( + scheme.CredentialName, + Options.DefaultQueryName); + + return parser.Parse(body, fieldName); + } + finally + { + context.Request.Body.Position = originalPosition; + } + } + + /// + /// Reads up to bytes from the body and decodes them as UTF-8. + /// Returns null when the body is larger than the limit. + /// + private static async Task ReadBoundedBodyAsync(Stream body, long maxBytes) + { + var buffer = new byte[4096]; + + using var ms = new MemoryStream(); + + long total = 0; + int read; + while ((read = await body.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0) + { + total += read; + + if (total > maxBytes) + return null; + + ms.Write(buffer, 0, read); + } + + ms.Position = 0; + + using var reader = new StreamReader( + ms, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + leaveOpen: false); + + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs new file mode 100644 index 0000000..3894675 --- /dev/null +++ b/src/Host/Security/LocationExtractors/CookieApiKeyLocationExtractor.cs @@ -0,0 +1,52 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from an HTTP request cookie. +/// +/// +/// +/// The cookie name is resolved from the scheme's +/// and falls back to +/// when no +/// explicit credential name is provided. +/// +/// +public sealed class CookieApiKeyLocationExtractor( + IOptions options) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Cookie; + + /// + /// Extracts an API key from the request cookie collection. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// + /// The normalized API key, or null if the specified cookie is not present. + /// + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var cookieName = ResolveName(scheme.CredentialName, Options.DefaultCookieName); + + if (context.Request.Cookies.TryGetValue(cookieName, out var cookie)) + { + return Task.FromResult( + ApiKeyValueNormalizer.Normalize(cookie)); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs new file mode 100644 index 0000000..2424b20 --- /dev/null +++ b/src/Host/Security/LocationExtractors/GrpcMetadataApiKeyLocationExtractor.cs @@ -0,0 +1,64 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from gRPC request metadata represented as HTTP/2 headers. +/// +/// +/// +/// gRPC metadata keys are transmitted as lowercase HTTP/2 headers. The +/// configured credential name is therefore normalized to lowercase before +/// looking it up in the request headers. +/// +/// +/// Hosts that cannot represent gRPC metadata as HTTP headers should reject the +/// configuration rather than silently falling back to another location. +/// +/// +public sealed class GrpcMetadataApiKeyLocationExtractor( + IOptions options, + ILogger logger) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.GrpcMetadata; + + /// + /// Extracts an API key from gRPC request metadata represented as an HTTP/2 header. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// The normalized API key, or null if the metadata entry is not present. + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var credentialName = string.IsNullOrWhiteSpace(scheme.CredentialName) + ? Options.DefaultHeaderName.ToLowerInvariant() + : scheme.CredentialName.ToLowerInvariant(); + + if (credentialName == Options.DefaultHeaderName.ToLowerInvariant()) + { + logger.LogDebug( + "Reading gRPC metadata for scheme {Scheme} as HTTP header '{HeaderName}'", + scheme.Name, + credentialName); + } + + if (context.Request.Headers.TryGetValue(credentialName, out var header)) + { + return Task.FromResult( + ApiKeyValueNormalizer.Normalize(header.ToString())); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs new file mode 100644 index 0000000..614ef3c --- /dev/null +++ b/src/Host/Security/LocationExtractors/HeaderApiKeyLocationExtractor.cs @@ -0,0 +1,59 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from an HTTP request header or transport metadata. +/// +/// +/// +/// The header name is resolved from the scheme's +/// and falls back to when no +/// explicit credential name is provided. +/// +/// +/// When is enabled, a +/// case-insensitive Bearer prefix is removed from the extracted value, allowing the header +/// to contain either a plain API key or a bearer token. +/// +/// +public sealed class HeaderApiKeyLocationExtractor( + IOptions options) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Header; + + /// + /// Extracts an API key from the specified request header. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// + /// The normalized API key, or null if the specified header is not present. + /// + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var headerName = ResolveName(scheme.CredentialName, Options.DefaultHeaderName); + + if (context.Request.Headers.TryGetValue(headerName, out var header)) + { + var value = header.ToString(); + return Task.FromResult( + Options.StripBearerPrefix + ? ApiKeyValueNormalizer.StripBearerPrefix(value) + : ApiKeyValueNormalizer.Normalize(value)); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs new file mode 100644 index 0000000..f5194de --- /dev/null +++ b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractor.cs @@ -0,0 +1,24 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key credential from a request for a single +/// . +/// +public interface IApiKeyLocationExtractor +{ + /// + /// Gets the credential location this extractor handles. + /// + AuthKitApiKeyLocation Location { get; } + + /// + /// Attempts to extract the API key for the given scheme from the request. + /// + /// The current . + /// The security scheme describing where the credential lives. + /// The extracted API key, or null when none is present. + Task ExtractAsync(HttpContext context, AuthKitSecuritySchemeDescriptor scheme); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs new file mode 100644 index 0000000..3a51bf7 --- /dev/null +++ b/src/Host/Security/LocationExtractors/IApiKeyLocationExtractorRegistry.cs @@ -0,0 +1,20 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.LocationExtractors; + +/// +/// Resolves the responsible for a given +/// . +/// +public interface IApiKeyLocationExtractorRegistry +{ + /// + /// Returns the extractor registered for the given location. + /// + /// The credential location to resolve. + /// The extractor handling the requested location. + /// + /// Thrown when no extractor is registered for the location. + /// + IApiKeyLocationExtractor Resolve(AuthKitApiKeyLocation location); +} \ No newline at end of file diff --git a/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs b/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs new file mode 100644 index 0000000..bf26f9d --- /dev/null +++ b/src/Host/Security/LocationExtractors/QueryApiKeyLocationExtractor.cs @@ -0,0 +1,48 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Microsoft.Extensions.Options; + +using Host.Security.Options; + +namespace Host.Security.LocationExtractors; + +/// +/// Extracts an API key from an HTTP request query parameter. +/// +/// +/// The query parameter name is resolved from the scheme's +/// and falls back +/// to when no +/// explicit credential name is provided. +/// +public sealed class QueryApiKeyLocationExtractor( + IOptions options) + : ApiKeyLocationExtractorBase(options) +{ + /// + /// Gets the API key location handled by this extractor. + /// + public override AuthKitApiKeyLocation Location => + AuthKitApiKeyLocation.Query; + + /// + /// Extracts an API key from the specified request query parameter. + /// + /// The current HTTP request context. + /// The security scheme describing the credential. + /// The normalized API key, or null if the query parameter is not present. + public override Task ExtractAsync( + HttpContext context, + AuthKitSecuritySchemeDescriptor scheme) + { + var queryName = ResolveName(scheme.CredentialName, Options.DefaultQueryName); + + if (context.Request.Query.TryGetValue(queryName, out var query)) + { + return Task.FromResult( + ApiKeyValueNormalizer.Normalize(query.ToString())); + } + + return Task.FromResult(null); + } +} \ No newline at end of file diff --git a/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs new file mode 100644 index 0000000..9846eb0 --- /dev/null +++ b/src/Host/Security/Middleware/ApiKeyCredentialExtractor.cs @@ -0,0 +1,138 @@ +using System.Security; +using System.Security.Claims; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Security.LocationExtractors; +using Host.Security.Validation; +using Microsoft.AspNetCore.Http.Features; + +namespace Host.Security.Middleware; + +/// +/// Pipeline middleware that authenticates requests using the API key scheme +/// declared on the current request's endpoint. +/// +/// +/// +/// The scheme is resolved per request from endpoint metadata +/// () and the host's +/// rather than from an ambient descriptor +/// registered in the container. Endpoints that do not declare a scheme are +/// passed through untouched. +/// +/// +/// Authentication is fail-closed: a request that carries a credential for a +/// declared scheme and cannot be authenticated is rejected with +/// 401 Unauthorized. Only requests that carry no credential at all are +/// passed through so downstream middleware can decide how to handle them. +/// +/// +/// An is resolved lazily from the request's +/// service provider, and only when a credential is actually present. A missing +/// validator registration is treated as a host configuration error and the +/// request fails closed with 401 Unauthorized. +/// +/// +public sealed class ApiKeyCredentialExtractor( + RequestDelegate next, + ISecuritySchemeRegistry registry, + IApiKeyLocationExtractorRegistry locationRegistry, + ILogger logger) +{ + private const string AuthenticationType = "ApiKey"; + + /// + /// Extracts and validates the API key declared for the current request. + /// + /// The current . + public async Task InvokeAsync(HttpContext context) + { + var scheme = ResolveScheme(context); + if (scheme is null) + { + await next(context); + return; + } + + string? apiKey; + try + { + apiKey = await locationRegistry.Resolve(scheme.In).ExtractAsync(context, scheme); + } + catch (FormatException ex) + { + logger.LogWarning(ex, "Failed to extract API key from {Location} for scheme {Scheme}", scheme.In, scheme.Name); + WriteUnauthorized(context); + return; + } + + if (string.IsNullOrEmpty(apiKey)) + { + await next(context); + return; + } + + var validator = context.RequestServices.GetService(); + if (validator is null) + { + logger.LogError( + "Scheme {Scheme} is declared on an endpoint, but no IApiKeyValidator is registered. " + + "Register a validator during host or plugin configuration.", + scheme.Name); + WriteUnauthorized(context); + return; + } + + try + { + var principal = await validator.ValidateAsync(apiKey); + if (principal is null) + { + logger.LogWarning("API key rejected by validator for scheme {Scheme}", scheme.Name); + WriteUnauthorized(context); + return; + } + + var identity = new ClaimsIdentity(principal.Claims, AuthenticationType); + context.User = new ClaimsPrincipal(identity); + logger.LogDebug("API key validated for {Subject} via scheme {Scheme}", principal.Subject, scheme.Name); + } + catch (UnauthorizedAccessException ex) + { + logger.LogWarning(ex, "Failed to validate API key for scheme {Scheme}", scheme.Name); + WriteUnauthorized(context); + return; + } + catch (SecurityException ex) + { + logger.LogWarning(ex, "Failed to validate API key for scheme {Scheme}", scheme.Name); + WriteUnauthorized(context); + return; + } + + await next(context); + } + + private AuthKitSecuritySchemeDescriptor? ResolveScheme(HttpContext context) + { + var schemeName = context.Features.Get()?.Endpoint + ?.Metadata.GetMetadata()?.SchemeName; + + if (string.IsNullOrEmpty(schemeName)) + return null; + + if (!registry.TryGet(schemeName, out var scheme)) + { + throw new InvalidOperationException( + $"Endpoint declares security scheme '{schemeName}', but no enabled plugin contributes a scheme " + + $"with that name. The request cannot be authenticated."); + } + + return scheme; + } + + private static void WriteUnauthorized(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.Headers.WWWAuthenticate = "ApiKey"; + } +} \ No newline at end of file diff --git a/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs b/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs new file mode 100644 index 0000000..964db70 --- /dev/null +++ b/src/Host/Security/Options/ApiKeyCredentialExtractorOptions.cs @@ -0,0 +1,87 @@ +using AuthKit.Plugins.Abstractions; + +namespace Host.Security.Options; + +/// +/// Options for configuring API key credential extraction. +/// +/// +/// +/// Default names are used by the location extractors whenever the declaring +/// scheme does not specify an explicit credential name. +/// +/// +/// controls how much of the request body is +/// buffered in memory before it spills to a temporary file. It is not a hard +/// size limit — see for the optional extraction limit. +/// +/// +public sealed class ApiKeyCredentialExtractorOptions +{ + /// + /// Default value for . + /// + public const string DefaultHeaderNameValue = "X-Api-Key"; + + /// + /// Default value for . + /// + public const string DefaultQueryNameValue = "api_key"; + + /// + /// Default value for . + /// + public const string DefaultCookieNameValue = "api_key"; + + /// + /// Gets or sets the memory threshold, in bytes, used when buffering the + /// request body for extraction. + /// Bodies larger than this value spill to a temporary file (default: + /// 1 MB). + /// + public long BufferThreshold { get; set; } = 1_048_576; // 1 MB + + /// + /// Gets or sets the maximum request body size, in bytes, read for + /// credential extraction. + /// Bodies larger than this value are rejected instead of being read in + /// full. Requests without a known Content-Length are read through + /// the same bounded path. A value of 0 disables the limit and reads + /// the entire body that fits within spill + /// behavior (default: 0, no limit). + /// + public long MaxBodySize { get; set; } + + /// + /// Gets or sets a value indicating whether a case-insensitive + /// Bearer prefix is removed from values extracted from the + /// before validation (default: + /// false). + /// + /// + /// When disabled, headers are normalized only (whitespace and wrapping + /// quotes are trimmed) and a Bearer prefix is passed through to the + /// validator unchanged. Enable this option only for schemes where callers + /// are expected to share the header between plain API keys and bearer + /// tokens. + /// + public bool StripBearerPrefix { get; set; } + + /// + /// Gets or sets the header name to use when the scheme does not specify + /// one (default: X-Api-Key). + /// + public string DefaultHeaderName { get; set; } = DefaultHeaderNameValue; + + /// + /// Gets or sets the query parameter name to use when the scheme does not + /// specify one (default: api_key). + /// + public string DefaultQueryName { get; set; } = DefaultQueryNameValue; + + /// + /// Gets or sets the cookie name to use when the scheme does not specify + /// one (default: api_key). + /// + public string DefaultCookieName { get; set; } = DefaultCookieNameValue; +} \ No newline at end of file diff --git a/src/Host/Security/SecuritySchemeRegistry.cs b/src/Host/Security/SecuritySchemeRegistry.cs new file mode 100644 index 0000000..39e0c0d --- /dev/null +++ b/src/Host/Security/SecuritySchemeRegistry.cs @@ -0,0 +1,58 @@ +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Plugins; + +namespace Host.Security; + +/// +/// The default built from the security +/// schemes contributed by all loaded plugins. +/// +/// +/// +/// Scheme names are unique across plugins. If two plugins declare the same +/// scheme name the registry treats it as a host configuration error rather +/// than silently resolving to either plugin's descriptor. +/// +/// +public sealed class SecuritySchemeRegistry : ISecuritySchemeRegistry +{ + private readonly IReadOnlyDictionary _schemes; + + /// + /// Creates a registry over the supplied loaded plugins. + /// + /// The plugins contributing security schemes. + /// + /// More than one plugin declares the same security scheme name. + /// + public SecuritySchemeRegistry(IReadOnlyList plugins) + { + ArgumentNullException.ThrowIfNull(plugins); + _schemes = BuildSchemeMap(plugins); + } + + private static IReadOnlyDictionary BuildSchemeMap( + IReadOnlyList plugins) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var plugin in plugins) + { + foreach (var (name, descriptor) in plugin.Plugin.GetSecuritySchemes()) + { + if (!map.TryAdd(name, descriptor)) + { + throw new InvalidOperationException( + $"Security scheme '{name}' is declared by more than one plugin. " + + $"Scheme names must be unique across all enabled plugins."); + } + } + } + + return map; + } + + /// + public bool TryGet(string schemeName, out AuthKitSecuritySchemeDescriptor result) => + _schemes.TryGetValue(schemeName, out result!); +} \ No newline at end of file diff --git a/src/Host/Security/SecurityServiceCollectionExtensions.cs b/src/Host/Security/SecurityServiceCollectionExtensions.cs new file mode 100644 index 0000000..0201c4b --- /dev/null +++ b/src/Host/Security/SecurityServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using AuthKit.Plugins.Abstractions; +using Host.Security.BodyParsers; +using Host.Security.LocationExtractors; +using Host.Security.Options; +using Microsoft.Extensions.DependencyInjection; + +namespace Host.Security; + +/// +/// Provides dependency injection configuration for API key credential extraction. +/// +/// +/// +/// Centralizes the registration of credential location strategies and request +/// body parsers used to authenticate API key schemes. +/// +/// +/// New locations or body formats are supported by registering additional +/// or +/// implementations — no existing code needs to change. +/// +/// +public static class SecurityServiceCollectionExtensions +{ + /// + /// Registers the API key credential extraction strategies and their options. + /// + /// The service collection to configure. + /// + /// An optional callback used to configure . + /// + /// The configured service collection. + public static IServiceCollection AddApiKeyCredentialExtraction( + this IServiceCollection services, + Action? configure = null) + { + services.Configure(configure ?? (_ => { })); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/Host/Security/Validation/ApiKeyPrincipal.cs b/src/Host/Security/Validation/ApiKeyPrincipal.cs new file mode 100644 index 0000000..7adaccd --- /dev/null +++ b/src/Host/Security/Validation/ApiKeyPrincipal.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; + +namespace Host.Security.Validation; + +/// +/// Represents the principal produced after successful API key validation. +/// +/// +/// +/// Contains the stable subject identifier and claims associated with the +/// authenticated API key. +/// +/// +public sealed class ApiKeyPrincipal +{ + /// + /// Gets or sets the stable identifier of the subject that owns the + /// validated API key. + /// + public string Subject { get; set; } = string.Empty; + + /// + /// Gets or sets the claims associated with the authenticated identity. + /// + public IEnumerable Claims { get; set; } = []; +} \ No newline at end of file diff --git a/src/Host/Security/Validation/IApiKeyValidator.cs b/src/Host/Security/Validation/IApiKeyValidator.cs new file mode 100644 index 0000000..45075ac --- /dev/null +++ b/src/Host/Security/Validation/IApiKeyValidator.cs @@ -0,0 +1,19 @@ +namespace Host.Security.Validation; + +/// +/// Validates extracted API key credentials and produces authenticated principals. +/// +/// +/// Implementations determine how API keys are authenticated and may validate +/// them against a plugin database or another credential store. +/// +public interface IApiKeyValidator +{ + /// + /// Validates an API key and produces the authenticated principal. + /// + /// The raw API key credential. + /// An when the key is valid; otherwise, null + /// + Task ValidateAsync(string apiKey); +} \ No newline at end of file diff --git a/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs index 4507888..32edca4 100644 --- a/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs +++ b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs @@ -1,5 +1,7 @@ namespace Host.ServiceDiscovery; +using System.Reflection; + /// /// Filters types during service discovery for automatic DI registration. /// @@ -88,11 +90,17 @@ private static string GetFeature(string ns) return parts.Length > 1 ? parts[1] : "Global"; } + private static bool IsRecord(Type type) + => type.GetMethod( + "$", + BindingFlags.Public | BindingFlags.Instance) is not null; + /// /// Applies rules to decide inclusion. /// private static bool ShouldInclude(Type type, string ns, string layer, ServiceDiscoveryOptions opts) - => !(opts.SkipInterfaces && type.IsInterface) + => !IsRecord(type) + && !(opts.SkipInterfaces && type.IsInterface) && !(opts.SkipExceptions && typeof(Exception).IsAssignableFrom(type)) && !IsRecordLike(type) && !opts.ExcludedTypes.Contains(type) diff --git a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj index a956c76..7065a0f 100644 --- a/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj +++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj @@ -6,6 +6,7 @@ enable false false + false @@ -17,5 +18,4 @@ - - + \ No newline at end of file diff --git a/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs index dcc9c01..0b7af4f 100644 --- a/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs +++ b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs @@ -9,11 +9,15 @@ namespace AuthKit.Plugins.Abstractions; /// on the transport used by the incoming request, such as HTTP or gRPC. /// /// -/// For HTTP requests, API keys may be provided through request headers, query -/// string parameters, or cookies. For gRPC requests, API keys are typically -/// provided through request metadata, which is represented by -/// . -/// + /// For HTTP requests, API keys may be provided through request headers, query + /// string parameters, or cookies. For gRPC requests, API keys are typically + /// provided through gRPC request metadata. + /// + /// + /// Every value describes a separate transport. In particular, + /// is explicit gRPC metadata and is distinct from + /// ; a host must never silently convert one to the other. + /// /// /// The selected location determines where the AuthKit authentication pipeline /// searches for the API key before attempting validation. @@ -30,7 +34,11 @@ public enum AuthKitApiKeyLocation /// header, such as X-Api-Key. /// /// - /// For gRPC requests, the API key is retrieved from the request metadata. + /// For gRPC requests, the API key may also be retrieved from the request + /// metadata for backward-compatible plugins. New gRPC configurations should + /// prefer the explicit location. This value + /// remains distinct from : a host must never + /// silently convert to . /// /// Header, @@ -61,5 +69,52 @@ public enum AuthKitApiKeyLocation /// and is not available for native gRPC calls. /// /// - Cookie + Cookie, + + /// + /// Retrieves the API key from gRPC metadata. + /// + /// + /// + /// The API key is expected to be supplied through gRPC request metadata + /// (key-value pairs in the gRPC metadata headers). + /// + /// + /// This location is distinct from and is specifically + /// for gRPC transport. It must not be silently treated as equivalent to + /// even though both use metadata-like structures. + /// + /// + /// A host that does not support gRPC metadata credential extraction must + /// explicitly reject this configuration. + /// + /// + GrpcMetadata = 3, + + /// + /// Retrieves the API key from the request body. + /// + /// + /// + /// The API key is expected to be supplied within the request body payload. + /// The exact format (e.g., JSON field, form field) is determined by the + /// host implementation. + /// + /// + /// This location is distinct from , , + /// and . It must not be silently converted to any + /// other location. + /// + /// + /// A host that does not support body-based credential extraction must + /// explicitly reject this configuration. + /// + /// + /// This value defines only the credential transport location. It does not + /// define or change the authentication mechanism. For example, when used + /// with , it means the API + /// key credential is transported through the request body. + /// + /// + Body = 4 } diff --git a/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs b/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs index 0c7e762..50c1328 100644 --- a/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs +++ b/src/Plugins/Abstractions/Contracts/Plugins/PluginMetadataAttribute.cs @@ -1,7 +1,7 @@ namespace AuthKit.Plugins.Abstractions.Contracts.Plugins; /// -/// Atrubut metadanych pluginu AuthKit. +/// Atrybut metadanych pluginu AuthKit. /// [AttributeUsage(AttributeTargets.Class)] public class PluginMetadataAttribute( diff --git a/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs b/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs index f23243f..5d9edbb 100644 --- a/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs +++ b/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeDescriptor.cs @@ -24,8 +24,29 @@ public sealed record AuthKitSecuritySchemeDescriptor /// /// The unique name used to identify the security scheme. /// + /// + /// + /// is a transport-agnostic scheme identity. It is used in logs, + /// validation messages, and as the scheme key in OpenAPI documents. It must not be + /// reused as the transport credential field name. + /// + /// public required string Name { get; init; } + /// + /// The transport-specific field name used to locate the credential, such as the + /// header, query parameter, cookie, gRPC metadata key, or body field name. + /// + /// + /// + /// is independent from : a scheme can + /// be identified as DevTokens while its credential travels as an + /// X-Api-Key header. When not set, hosts fall back to their configured default + /// field name for the selected location. + /// + /// + public string? CredentialName { get; init; } + /// /// The type of authentication mechanism implemented by the security scheme. /// diff --git a/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeType.cs b/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeType.cs index 5b296a8..64e273b 100644 --- a/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeType.cs +++ b/src/Plugins/Abstractions/Contracts/SecuritySchemes/AuthKitSecuritySchemeType.cs @@ -16,6 +16,12 @@ namespace AuthKit.Plugins.Abstractions; /// level. It does not itself perform authentication, validate credentials, /// or establish an authentication session. /// +/// +/// Numeric values are part of the plugin contract and MUST NOT be reused or +/// renumbered. Each value must remain unique so an authentication mechanism +/// can never be confused with a different mechanism that happens to share a +/// value. +/// /// public enum AuthKitSecuritySchemeType { @@ -27,7 +33,7 @@ public enum AuthKitSecuritySchemeType /// . /// Common locations include an HTTP header, query parameter, or cookie. /// - ApiKey, + ApiKey = 0, /// /// Authentication using an HTTP authentication scheme. @@ -38,7 +44,7 @@ public enum AuthKitSecuritySchemeType /// Examples include Basic, Bearer, and other HTTP /// authentication schemes. /// - Http, + Http = 1, /// /// Authentication using the OAuth 2.0 authorization framework. @@ -48,14 +54,46 @@ public enum AuthKitSecuritySchemeType /// gets an access token from an authorization server and presents /// that token when accessing protected resources. /// - OAuth2, + OAuth2 = 2, + + /// + /// Mutual TLS authentication using client certificate. + /// + MutualTls = 3, + + /// + /// Session-based authentication. + /// A cookie may be used as transport mechanism but does not define the authentication mechanism itself. + /// + Session = 4, + + /// + /// A plugin defined authentication mechanism that is not covered by the built-in security scheme types. + /// + Custom = 5, + + /// + /// HTTP Basic authentication using the Authorization header. + /// + Basic = 6, /// /// Authentication using the OpenID Connect identity layer. /// /// + /// /// OpenID Connect extends OAuth 2.0 with an identity layer and is used /// to authenticate users through an OpenID Connect identity provider. + /// + /// + /// The value 7 is intentional: the host already occupies values + /// 0 (), 1 (), and + /// 2 (), while values 3..6 belong + /// to , , , + /// and . Historically + /// aliased at value 2; it now has a distinct + /// value so OAuth 2.0 and OpenID Connect can never be confused. + /// /// - OpenIdConnect + OpenIdConnect = 7 } diff --git a/src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs b/src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs new file mode 100644 index 0000000..3c50fb8 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/SecuritySchemes/SecuritySchemeAttribute.cs @@ -0,0 +1,27 @@ +namespace AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; + +/// +/// Declares the security scheme that protects an endpoint. +/// +/// +/// +/// The host applies this attribute to endpoint metadata so that request-aware +/// security resolution can select the matching +/// for the current request, +/// instead of relying on a single ambient registered descriptor. +/// +/// +/// The scheme name must match a key returned by an enabled plugin's +/// ; otherwise the host rejects +/// requests to the endpoint as a configuration error. +/// +/// +/// The unique name of the security scheme protecting the endpoint. +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class SecuritySchemeAttribute(string schemeName) : Attribute +{ + /// + /// Gets the unique name of the security scheme protecting the endpoint. + /// + public string SchemeName { get; } = schemeName; +} \ No newline at end of file diff --git a/tests/Host/ApiKeyCredentialExtractorTests.cs b/tests/Host/ApiKeyCredentialExtractorTests.cs new file mode 100644 index 0000000..b484912 --- /dev/null +++ b/tests/Host/ApiKeyCredentialExtractorTests.cs @@ -0,0 +1,205 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Security; +using Host.Security.LocationExtractors; +using Host.Security.Middleware; +using Host.Security.Validation; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AuthKit.Host.Tests; + +public class ApiKeyCredentialExtractorTests +{ + private const string SchemeName = "DevTokens"; + + private sealed class FakeRegistry(string registeredName = SchemeName) : ISecuritySchemeRegistry + { + private readonly AuthKitSecuritySchemeDescriptor _descriptor = new() + { + Name = registeredName, + Type = AuthKitSecuritySchemeType.ApiKey, + In = AuthKitApiKeyLocation.Header, + CredentialName = "X-Api-Key" + }; + + public bool TryGet(string schemeName, out AuthKitSecuritySchemeDescriptor result) + { + result = _descriptor; + return string.Equals(schemeName, _descriptor.Name, StringComparison.OrdinalIgnoreCase); + } + } + + private sealed class FakeLocationRegistry(IApiKeyLocationExtractor extractor) : IApiKeyLocationExtractorRegistry + { + public IApiKeyLocationExtractor Resolve(AuthKitApiKeyLocation location) => extractor; + } + + private sealed class FakeExtractor(string? value) : IApiKeyLocationExtractor + { + public AuthKitApiKeyLocation Location => AuthKitApiKeyLocation.Header; + + public Task ExtractAsync(HttpContext context, AuthKitSecuritySchemeDescriptor scheme) + => Task.FromResult(value); + } + + private sealed class FakeValidator(ApiKeyPrincipal? principal) : IApiKeyValidator + { + public Task ValidateAsync(string apiKey) => Task.FromResult(principal); + } + + private sealed class PipelineProbe + { + public bool NextCalled { get; private set; } + + public RequestDelegate Next => context => + { + NextCalled = true; + return Task.CompletedTask; + }; + } + + private sealed class EndpointFeature : IEndpointFeature + { + public Endpoint? Endpoint { get; set; } + } + + private static HttpContext CreateContext(string? schemeName = SchemeName) + { + var context = new DefaultHttpContext(); + + if (schemeName is not null) + { + var endpoint = new Endpoint( + requestDelegate: _ => Task.CompletedTask, + metadata: new EndpointMetadataCollection(new SecuritySchemeAttribute(schemeName)), + displayName: "test"); + context.Features.Set(new EndpointFeature { Endpoint = endpoint }); + } + + return context; + } + + private static void WithValidator(HttpContext context, IApiKeyValidator validator) + { + var serviceProvider = new ServiceCollection() + .AddSingleton(validator) + .BuildServiceProvider(); + context.RequestServices = serviceProvider; + } + + private static ApiKeyCredentialExtractor CreateMiddleware( + ISecuritySchemeRegistry registry, + IApiKeyLocationExtractorRegistry locationRegistry, + RequestDelegate next) => + new(next, registry, locationRegistry, NullLogger.Instance); + + [Fact] + public async Task EndpointWithoutSchemeAttribute_ContinuesPipeline() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(schemeName: null); + WithValidator(context, new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + await middleware.InvokeAsync(context); + + Assert.True(state.NextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task ValidCredential_AuthenticatesPrincipalAndContinues() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(); + var principal = new ApiKeyPrincipal { Subject = "s1" }; + WithValidator(context, new FakeValidator(principal)); + await middleware.InvokeAsync(context); + + Assert.True(state.NextCalled); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.True(context.User.Identity?.IsAuthenticated); + Assert.Equal("ApiKey", context.User.Identity.AuthenticationType); + } + + [Fact] + public async Task MissingCredential_ContinuesPipeline() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor(null)), + state.Next); + + var context = CreateContext(); + WithValidator(context, new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + await middleware.InvokeAsync(context); + + Assert.True(state.NextCalled); + Assert.NotEqual(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InvalidCredential_FailsClosedWith401() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("wrong-key")), + state.Next); + + var context = CreateContext(); + WithValidator(context, new FakeValidator(null)); + await middleware.InvokeAsync(context); + + Assert.False(state.NextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + Assert.Equal("ApiKey", context.Response.Headers.WWWAuthenticate); + } + + [Fact] + public async Task NoValidatorRegistered_FailsClosedWith401() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(); + context.RequestServices = new ServiceCollection().BuildServiceProvider(); + await middleware.InvokeAsync(context); + + Assert.False(state.NextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task SchemeNotRegistered_FailsClosedAsConfigurationError() + { + var state = new PipelineProbe(); + var middleware = CreateMiddleware( + new FakeRegistry(), + new FakeLocationRegistry(new FakeExtractor("secret")), + state.Next); + + var context = CreateContext(schemeName: "UnknownScheme"); + WithValidator(context, new FakeValidator(new ApiKeyPrincipal { Subject = "s1" })); + + await Assert.ThrowsAsync(() => middleware.InvokeAsync(context)); + + Assert.False(state.NextCalled); + } +} \ No newline at end of file diff --git a/tests/Host/AuthKit.Host.Tests.csproj b/tests/Host/AuthKit.Host.Tests.csproj new file mode 100644 index 0000000..c3138fd --- /dev/null +++ b/tests/Host/AuthKit.Host.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file diff --git a/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs b/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs new file mode 100644 index 0000000..841a146 --- /dev/null +++ b/tests/Host/AuthKitOpenApiSecuritySchemeMapperTests.cs @@ -0,0 +1,235 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using Host.Configuration; +using Host.Security.Options; +using Microsoft.OpenApi; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies that every and +/// value is either mapped to a semantically +/// correct OpenAPI representation or explicitly rejected — never silently +/// mapped to a generic or unrelated scheme. +/// +public class AuthKitOpenApiSecuritySchemeMapperTests +{ + private static readonly OpenApiSpecVersion Version = OpenApiSpecVersion.OpenApi3_0; + + private static readonly OpenApiSpecVersion OpenApi3_1 = OpenApiSpecVersion.OpenApi3_1; + + private static AuthKitSecuritySchemeDescriptor Describe( + AuthKitSecuritySchemeType type, + AuthKitApiKeyLocation location = AuthKitApiKeyLocation.Header, + string? scheme = null) => + new() + { + Name = "scheme", + Type = type, + In = location, + Scheme = scheme, + Description = "desc" + }; + + [Theory] + [InlineData(AuthKitApiKeyLocation.Header, ParameterLocation.Header, ApiKeyCredentialExtractorOptions.DefaultHeaderNameValue)] + [InlineData(AuthKitApiKeyLocation.Query, ParameterLocation.Query, ApiKeyCredentialExtractorOptions.DefaultQueryNameValue)] + [InlineData(AuthKitApiKeyLocation.Cookie, ParameterLocation.Cookie, ApiKeyCredentialExtractorOptions.DefaultCookieNameValue)] + public void ApiKey_WithHttpLocations_MapsToApiKeyAtLocation( + AuthKitApiKeyLocation location, ParameterLocation expected, string expectedName) + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, location), Version); + + Assert.Equal(SecuritySchemeType.ApiKey, mapped.Type); + Assert.Equal(expected, mapped.In); + Assert.Equal(expectedName, mapped.Name); + } + + [Fact] + public void ApiKey_WithExplicitCredentialName_WinsOverDefault() + { + var descriptor = new AuthKitSecuritySchemeDescriptor + { + Name = "DevTokens", + CredentialName = "X-Dev-Key", + Type = AuthKitSecuritySchemeType.ApiKey, + In = AuthKitApiKeyLocation.Header, + Description = "desc" + }; + + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map(descriptor, Version); + + Assert.Equal(SecuritySchemeType.ApiKey, mapped.Type); + Assert.Equal(ParameterLocation.Header, mapped.In); + Assert.Equal("X-Dev-Key", mapped.Name); + } + + [Fact] + public void Http_MapsToHttpWithSchemePassthrough() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Http, scheme: "digest"), Version); + + Assert.Equal(SecuritySchemeType.Http, mapped.Type); + Assert.Equal("digest", mapped.Scheme); + } + + [Fact] + public void Basic_MapsExplicitlyToHttpBasic() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Basic), Version); + + Assert.Equal(SecuritySchemeType.Http, mapped.Type); + Assert.Equal("basic", mapped.Scheme); + Assert.Equal(ApiKeyCredentialExtractorOptions.DefaultHeaderNameValue, mapped.Name); + } + + [Fact] + public void OAuth2_MapsToOAuth2() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.OAuth2), Version); + + Assert.Equal(SecuritySchemeType.OAuth2, mapped.Type); + } + + [Fact] + public void OpenIdConnect_MapsToOpenIdConnect() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.OpenIdConnect), Version); + + Assert.Equal(SecuritySchemeType.OpenIdConnect, mapped.Type); + Assert.NotEqual(SecuritySchemeType.OAuth2, mapped.Type); + } + + [Theory] + [InlineData(AuthKitSecuritySchemeType.MutualTls)] + [InlineData(AuthKitSecuritySchemeType.Session)] + [InlineData(AuthKitSecuritySchemeType.Custom)] + public void UnrepresentableSchemeTypes_AreExplicitlyRejected(AuthKitSecuritySchemeType type) + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map(Describe(type), Version)); + } + + [Theory] + [InlineData(AuthKitApiKeyLocation.GrpcMetadata)] + [InlineData(AuthKitApiKeyLocation.Body)] + public void NonOpenApiLocations_AreExplicitlyRejected(AuthKitApiKeyLocation location) + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, location), Version)); + } + + [Fact] + public void UnknownSchemeType_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe((AuthKitSecuritySchemeType)999), Version)); + + Assert.Contains("999", ex.Message); + } + + [Fact] + public void UnknownApiKeyLocation_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, (AuthKitApiKeyLocation)999), Version)); + + Assert.Contains("999", ex.Message); + } + + [Fact] + public void NullDescriptor_IsRejected() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map(null!, Version)); + } + + [Fact] + public void NoFallback_MutualTlsDoesNotResolveToHttp() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.MutualTls), Version)); + } + + [Fact] + public void MutualTls_UnderOpenApi3_1_MapsToNativeMutualTLS() + { + var mapped = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.MutualTls), OpenApi3_1); + + Assert.Equal(SecuritySchemeType.MutualTLS, mapped.Type); + } + + [Fact] + public void NoFallback_SessionDoesNotResolveToApiKeyOrHttp() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Session), Version)); + } + + [Fact] + public void Session_IsDistinctFromApiKeyWithCookieTransport() + { + var sessionDescriptor = Describe(AuthKitSecuritySchemeType.Session); + var apiKeyInCookie = Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.Cookie); + + Assert.NotEqual(apiKeyInCookie.Type, sessionDescriptor.Type); + + var mappedApiKey = AuthKitOpenApiSecuritySchemeMapper.Map(apiKeyInCookie, Version); + + Assert.Equal(SecuritySchemeType.ApiKey, mappedApiKey.Type); + Assert.Equal(ParameterLocation.Cookie, mappedApiKey.In); + + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map(sessionDescriptor, Version)); + } + + [Fact] + public void NoFallback_CustomDoesNotResolveToBuiltInScheme() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Custom), Version)); + } + + [Fact] + public void NoFallback_BasicIsDistinctFromGenericHttp() + { + var generic = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Http), Version); + var basic = AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.Basic), Version); + + Assert.Equal(SecuritySchemeType.Http, generic.Type); + Assert.Equal(SecuritySchemeType.Http, basic.Type); + Assert.NotEqual(generic.Scheme, basic.Scheme); + Assert.Equal("basic", basic.Scheme); + } + + [Fact] + public void NoFallback_GrpcMetadataDoesNotResolveToHeader() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.GrpcMetadata), Version)); + } + + [Fact] + public void NoFallback_BodyDoesNotResolveToAnotherLocation() + { + Assert.Throws(() => + AuthKitOpenApiSecuritySchemeMapper.Map( + Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.Body), Version)); + } +} \ No newline at end of file diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs new file mode 100644 index 0000000..f98aa70 --- /dev/null +++ b/tests/Host/PluginContractValidatorTests.cs @@ -0,0 +1,213 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AuthKit.Host.Tests; + +/// +/// Verifies that the host recognizes every extended security scheme contract value and +/// explicitly accepts (or rejects) it instead of silently treating it as supported. +/// +public class PluginContractValidatorTests +{ + private static readonly Microsoft.Extensions.Logging.ILogger Logger = NullLogger.Instance; + + private sealed class FakePlugin(AuthKitSecuritySchemeDescriptor descriptor) : IAuthKitPlugin + { + private readonly IReadOnlyDictionary _schemes = new Dictionary + { + [descriptor.Name] = descriptor + }; + + public string Name => "Fake"; + public string Version => "1.0.0"; + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + public IReadOnlyDictionary GetSecuritySchemes() => _schemes; + } + + private static AuthKitSecuritySchemeDescriptor Describe( + AuthKitSecuritySchemeType type, + AuthKitApiKeyLocation location = AuthKitApiKeyLocation.Header, + string? description = null) => + new() + { + Name = "scheme", + Type = type, + In = location, + Description = description + }; + + private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger + { + public List Warnings { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, + Microsoft.Extensions.Logging.EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == Microsoft.Extensions.Logging.LogLevel.Warning) + Warnings.Add(formatter(state, exception)); + } + } + + [Theory] + [InlineData(AuthKitSecuritySchemeType.ApiKey)] + [InlineData(AuthKitSecuritySchemeType.Http)] + [InlineData(AuthKitSecuritySchemeType.OAuth2)] + [InlineData(AuthKitSecuritySchemeType.OpenIdConnect)] + public void ImplementedSchemeTypes_AreAccepted(AuthKitSecuritySchemeType type) + { + PluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger); + } + + [Theory] + [InlineData(AuthKitApiKeyLocation.Header)] + [InlineData(AuthKitApiKeyLocation.Query)] + [InlineData(AuthKitApiKeyLocation.Cookie)] + [InlineData(AuthKitApiKeyLocation.GrpcMetadata)] + [InlineData(AuthKitApiKeyLocation.Body)] + public void HostLocations_AreAccepted(AuthKitApiKeyLocation location) + { + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, location)), Logger); + } + + [Theory] + [InlineData(AuthKitSecuritySchemeType.MutualTls)] + [InlineData(AuthKitSecuritySchemeType.Session)] + [InlineData(AuthKitSecuritySchemeType.Custom)] + [InlineData(AuthKitSecuritySchemeType.Basic)] + public void UnimplementedSchemeTypes_AreExplicitlyRejected(AuthKitSecuritySchemeType type) + { + Assert.Throws(() => + PluginContractValidator.Validate(new FakePlugin(Describe(type)), Logger)); + } + + [Fact] + public void UnknownSchemeType_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe((AuthKitSecuritySchemeType)999)), Logger)); + + Assert.Contains("unknown", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("999", ex.Message); + } + + [Fact] + public void UnknownApiKeyLocation_IsExplicitlyRejected() + { + var ex = Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, (AuthKitApiKeyLocation)999)), + Logger)); + + Assert.Contains("unknown", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("999", ex.Message); + } + + [Fact] + public void CustomValidator_CanEnableAdditionalSchemeTypes() + { + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Basic + ]); + + custom.Validate(new FakePlugin(Describe(AuthKitSecuritySchemeType.Basic)), Logger); + } + + [Fact] + public void CustomValidator_CanRestrictLocations() + { + var custom = PluginContractValidator.CreateCustom( + supportedApiKeyLocations: [AuthKitApiKeyLocation.Header]); + + Assert.Throws(() => + custom.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.ApiKey, AuthKitApiKeyLocation.Query)), + Logger)); + } + + [Fact] + public void NoFallback_CustomAndSessionAreNotTreatedAsSupported() + { + Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.Session)), Logger)); + + Assert.Throws(() => + PluginContractValidator.Validate( + new FakePlugin(Describe(AuthKitSecuritySchemeType.Custom)), Logger)); + } + + [Fact] + public void CustomScheme_WithoutUsageDocumentation_EmitsWarning() + { + var logger = new RecordingLogger(); + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Custom + ]); + + custom.Validate(new FakePlugin( + Describe(AuthKitSecuritySchemeType.Custom, description: null)), logger); + + Assert.Contains(logger.Warnings, w => + w.Contains("Custom", StringComparison.OrdinalIgnoreCase) + && w.Contains("description", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void CustomScheme_WithUsageDocumentation_DoesNotEmitWarning() + { + var logger = new RecordingLogger(); + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Custom + ]); + + custom.Validate(new FakePlugin( + Describe(AuthKitSecuritySchemeType.Custom, + description: "Plugin-defined API key verified against a plugin database.")), + logger); + + Assert.Empty(logger.Warnings); + } + + [Fact] + public void CustomWarning_NeverMapsCustomToAnotherScheme() + { + var logger = new RecordingLogger(); + var custom = PluginContractValidator.CreateCustom( + supportedSchemeTypes: + [ + .. PluginContractValidator.SupportedSchemeTypes, + AuthKitSecuritySchemeType.Custom + ]); + + custom.Validate(new FakePlugin( + Describe(AuthKitSecuritySchemeType.Custom, description: null)), logger); + + Assert.Contains(logger.Warnings, w => w.Contains("never mapped", StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj b/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj new file mode 100644 index 0000000..749d7a2 --- /dev/null +++ b/tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs b/tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs new file mode 100644 index 0000000..73446ad --- /dev/null +++ b/tests/Plugins/Abstractions/AuthKitApiKeyLocationTests.cs @@ -0,0 +1,77 @@ +using System; +using AuthKit.Plugins.Abstractions; +using Xunit; + +namespace AuthKit.Plugins.Abstractions.Tests.SecuritySchemes; + +public class AuthKitApiKeyLocationTests +{ + [Theory] + [InlineData(AuthKitApiKeyLocation.GrpcMetadata, "GrpcMetadata")] + [InlineData(AuthKitApiKeyLocation.Body, "Body")] + public void NewEnumValues_AreDefined(AuthKitApiKeyLocation location, string name) + { + var actualName = location.ToString(); + Assert.Equal(name, actualName); + } + + [Fact] + public void GrpcMetadata_HasCorrectNumericValue() + { + Assert.Equal(3, (int)AuthKitApiKeyLocation.GrpcMetadata); + } + + [Fact] + public void Body_HasCorrectNumericValue() + { + Assert.Equal(4, (int)AuthKitApiKeyLocation.Body); + } + + [Fact] + public void ExistingValues_AreUnchanged() + { + Assert.Equal(0, (int)AuthKitApiKeyLocation.Header); + Assert.Equal(1, (int)AuthKitApiKeyLocation.Query); + Assert.Equal(2, (int)AuthKitApiKeyLocation.Cookie); + } + + [Fact] + public void GrpcMetadata_IsDistinctFromHeader() + { + Assert.NotEqual(AuthKitApiKeyLocation.GrpcMetadata, AuthKitApiKeyLocation.Header); + Assert.NotEqual((int)AuthKitApiKeyLocation.GrpcMetadata, (int)AuthKitApiKeyLocation.Header); + } + + [Fact] + public void Body_IsDistinctFromAllExistingLocations() + { + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.Header); + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.Query); + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.Cookie); + Assert.NotEqual(AuthKitApiKeyLocation.Body, AuthKitApiKeyLocation.GrpcMetadata); + + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.Header); + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.Query); + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.Cookie); + Assert.NotEqual((int)AuthKitApiKeyLocation.Body, (int)AuthKitApiKeyLocation.GrpcMetadata); + } + + [Fact] + public void UnknownEnumValues_AreNotDefined() + { + var unknownValue = (AuthKitApiKeyLocation)(-1); + Assert.False(Enum.IsDefined(typeof(AuthKitApiKeyLocation), unknownValue)); + } + + [Fact] + public void NewValues_HaveUniqueNumericValues() + { + var newValues = new[] + { + AuthKitApiKeyLocation.GrpcMetadata, + AuthKitApiKeyLocation.Body + }; + + Assert.Equal(newValues.Length, newValues.Distinct().Count()); + } +} \ No newline at end of file diff --git a/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs b/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs new file mode 100644 index 0000000..5f2e166 --- /dev/null +++ b/tests/Plugins/Abstractions/AuthKitSecuritySchemeTypeTests.cs @@ -0,0 +1,151 @@ +using System; +using AuthKit.Plugins.Abstractions; +using Xunit; + +namespace AuthKit.Plugins.Abstractions.Tests.SecuritySchemes; + +public class AuthKitSecuritySchemeTypeTests +{ + [Theory] + [InlineData(AuthKitSecuritySchemeType.MutualTls, "MutualTls")] + [InlineData(AuthKitSecuritySchemeType.Session, "Session")] + [InlineData(AuthKitSecuritySchemeType.Custom, "Custom")] + [InlineData(AuthKitSecuritySchemeType.Basic, "Basic")] + public void NewEnumValues_AreDefined(AuthKitSecuritySchemeType type, string name) + { + var actualName = type.ToString(); + + Assert.Equal(name, actualName); + } + + [Fact] + public void AllNewValues_HaveXmlDocumentation() + { + var newValues = new[] + { + AuthKitSecuritySchemeType.MutualTls, + AuthKitSecuritySchemeType.Session, + AuthKitSecuritySchemeType.Custom, + AuthKitSecuritySchemeType.Basic + }; + + foreach (var value in newValues) + { + Assert.True(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), value), $"Value {value} is not defined."); + } + } + + [Fact] + public void ExistingValues_AreUnchanged() + { + var existingValues = new[] + { + AuthKitSecuritySchemeType.ApiKey, + AuthKitSecuritySchemeType.Http, + AuthKitSecuritySchemeType.OAuth2, + AuthKitSecuritySchemeType.OpenIdConnect + }; + + Assert.Equal(0, (int)AuthKitSecuritySchemeType.ApiKey); + Assert.Equal(1, (int)AuthKitSecuritySchemeType.Http); + Assert.Equal(2, (int)AuthKitSecuritySchemeType.OAuth2); + Assert.Equal(7, (int)AuthKitSecuritySchemeType.OpenIdConnect); + } + + [Fact] + public void OAuth2_AndOpenIdConnect_AreNoLongerAliased() + { + Assert.NotEqual((int)AuthKitSecuritySchemeType.OAuth2, (int)AuthKitSecuritySchemeType.OpenIdConnect); + Assert.NotEqual(AuthKitSecuritySchemeType.OAuth2, AuthKitSecuritySchemeType.OpenIdConnect); + } + + [Fact] + public void AllNamedValues_HaveUniqueNumericValues() + { + var names = Enum.GetNames(); + var values = names + .Select(name => (int)Enum.Parse(name)) + .ToArray(); + + Assert.Equal(values.Length, values.Distinct().Count()); + } + + [Fact] + public void Session_IsSemanticallyDistinctFromApiKey() + { + var sessionType = AuthKitSecuritySchemeType.Session; + var apiKeyType = AuthKitSecuritySchemeType.ApiKey; + + Assert.NotEqual(sessionType, apiKeyType); + Assert.NotEqual((int)sessionType, (int)apiKeyType); + } + + [Fact] + public void Basic_IsDistinguishableFromBearer() + { + Assert.Equal(AuthKitSecuritySchemeType.Basic, AuthKitSecuritySchemeType.Basic); + Assert.NotEqual(AuthKitSecuritySchemeType.Basic, AuthKitSecuritySchemeType.Http); + } + + [Fact] + public void Custom_DoesNotSilentlyMapToOtherSchemes() + { + var customType = AuthKitSecuritySchemeType.Custom; + + Assert.NotEqual(AuthKitSecuritySchemeType.ApiKey, customType); + Assert.NotEqual(AuthKitSecuritySchemeType.Http, customType); + } + + [Fact] + public void NewEnumValues_AreExplicitlyRecognizedByHost() + { + var newValues = new[] + { + AuthKitSecuritySchemeType.MutualTls, + AuthKitSecuritySchemeType.Session, + AuthKitSecuritySchemeType.Custom, + AuthKitSecuritySchemeType.Basic + }; + + foreach (var value in newValues) + { + Assert.True(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), value), "Host should recognize the new enum value."); + + foreach (var v in newValues) + { + if (v != value) + { + Assert.NotEqual(value, v); + } + } + } + } + + [Fact] + public void UnknownEnumValues_AreNotDefined() + { + var unknownValue = (AuthKitSecuritySchemeType)(-1); + + Assert.False(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), unknownValue)); + } + + [Fact] + public void UnsupportedSessionAuthentication_ProducesExplicitError() + { + var sessionType = AuthKitSecuritySchemeType.Session; + + Assert.True(Enum.IsDefined(typeof(AuthKitSecuritySchemeType), sessionType), "Session type should be defined."); + Assert.NotEqual(AuthKitSecuritySchemeType.ApiKey, sessionType); + } + + [Fact] + public void BasicAuthentication_IsDistinguishableFromBearerAndGenericHttp() + { + var basicType = AuthKitSecuritySchemeType.Basic; + var httpType = AuthKitSecuritySchemeType.Http; + + Assert.NotEqual(basicType, httpType); + Assert.NotEqual((int)basicType, (int)httpType); + Assert.Equal(AuthKitSecuritySchemeType.Basic, basicType); + } +}