- API->>AuthKit: Validate developer token via REST
- AuthKit->>Keycloak: Validate user session & roles
- AuthKit-->>API: DeveloperToken valid ✅
- API-->>SDK: 200 OK — Operation authorized
-```
+Plugins implement `IAuthKitPlugin` and are loaded from the directory configured by `AuthKit:PluginsPath` (defaults to `/plugins`). At startup the host:
+
+1. Discovers and loads plugin assemblies.
+2. Calls `ConfigureServices` to register each plugin's dependencies.
+3. Inserts any contributed `MiddlewareType` into the pipeline.
+4. Exposes contributed OpenAPI security schemes.
+5. Reports plugin health through `CheckHealthAsync`.
+
+This lets new SDK solutions ship as self-contained packages without changing the host project.
+
+## Built-in Plugins
+
+### DevTokens
+
+Issues and validates developer tokens used for SDK access. Exposed REST endpoints:
+
+| Method | Route | Description |
+| --- | --- | --- |
+| `POST` | `sdk/developer-tokens` | Create a developer token |
+| `GET` | `sdk/developer-tokens` | List developer tokens |
+| `GET` | `sdk/developer-tokens/{tokenId}` | Get a token by id |
+| `DELETE` | `sdk/developer-tokens/{tokenId}` | Delete a token |
+| `POST` | `sdk/tokens/verify` | Verify a developer token |
+| `POST` | `sdk/tokens/{tokenId}/revoke-rotate` | Revoke and rotate a token |
+
+Tokens are passed via the `X-Developer-Token` API-key header and enforced by the `DeveloperTokenMiddleware` and scope-based authorization (`DeveloperScopeRequirement`).
+
+## JWT & Key Management
+
+- Signing keys are generated as RSA keys, encrypted with the AES master key (`Encryption:AES_MASTER_KEY`), and persisted in the key store.
+- Public keys are published at `.well-known/jwks.json` for external signature verification.
+- Multiple active keys are supported simultaneously to allow seamless key rotation.
+- Token key bindings associate issued tokens with the signing key used to protect them.
+
+## Authentication
+
+User identity is validated with **Keycloak** JWT bearer authentication. Keycloak is configured through environment variables:
+
+| Variable | Description | Default |
+| --- | --- | --- |
+| `KEYCLOAK_URL` | Keycloak base URL | `http://keycloak:8080` |
+| `KEYCLOAK_REALM` | Keycloak realm | `authz` |
+| `KEYCLOAK_CLIENT_ID` | Keycloak client id | `workspace-authz` |
+
+Client roles from the `resource_access` claim are mapped to ASP.NET Core role claims for authorization.
+
+## Configuration
+
+Key `appsettings.json` sections:
-## External REST vs Internal gRPC Call Flow
-```mermaid
-flowchart TD
- Dev[Developer] -->|Authenticate| Keycloak[Keycloak JWT]
- Keycloak --> Dev
-
- Dev -->|Request Dev Token| AuthKit[AuthKit API]
- AuthKit -->|Validate Keycloak token| Keycloak
- AuthKit -->|Return Dev Token| Dev
-
- Dev -->|Request Service Token| AuthKitService[AuthKit API]
- AuthKitService -->|Validate Dev Token| AuthKit
- AuthKitService -->|Return Service Token| Dev
-
- Dev -->|Configure SDK| SDK[RyzeSdkClient]
-
- SDK -->|REST request| API_REST[Target API REST]
- SDK -->|gRPC request| API_GRPC[Target API gRPC]
-
- subgraph REST_Flow
- API_REST -->|Pass tokens to middleware| AuthKit_REST[AuthKit Middleware REST]
- AuthKit_REST -->|Validate Dev & Service Tokens| TokenDB[Token Database]
- AuthKit_REST -->|Validate Keycloak JWT| Keycloak
- AuthKit_REST -->|Return auth result| API_REST
- API_REST -->|200 OK / 403 Forbidden| SDK
- end
-
- subgraph gRPC_Flow
- API_GRPC -->|Pass tokens to middleware| AuthKit_GRPC[AuthKit Middleware gRPC]
- AuthKit_GRPC -->|Validate Dev & Service Tokens| TokenDB
- AuthKit_GRPC -->|Validate Keycloak JWT| Keycloak
- AuthKit_GRPC -->|Return auth result| API_GRPC
- API_GRPC -->|200 OK / 403 Forbidden| SDK
- end
+| Section | Purpose |
+| --- | --- |
+| `ConnectionStrings:Marten` | PostgreSQL connection for Marten |
+| `AuthKit:MaxDeveloperTokens` | Max tokens per developer (default `3`) |
+| `Encryption:AES_MASTER_KEY` | Master key for encrypting signing keys |
+| `Server:Issuer` | JWT issuer (`https://authkit.local`) |
+| `ServiceDiscovery` | Automatic DI registration rules (namespaces, layers, lifetime) |
+
+## Getting Started
+
+### Run with Docker
+
+```bash
+docker compose up --build
```
-## SDK Function Call Flow (AuthKit → gRPC Microservices)
-```mermaid
-flowchart TD
- classDef token fill:#fef3c7,stroke:#f59e0b,stroke-width:1px,color:#b45309;
- classDef service fill:#fef3c7,stroke:#fef3c7,stroke-width:1px,color:#92400e;
- classDef internal fill:#dbeafe,stroke:#3b82f6,stroke-width:1px,color:#1e40af;
-
-%% Actors
- Dev[Developer] -->|Has Keycloak JWT,
DeveloperToken,
ServiceToken| SDK[RyzeSdkClient]
- class Dev,SDK token;
-
-%% REST request from external SDK
- SDK -->|REST request with tokens| API_Controller[API Controller]
- API_Controller -->|Validate tokens internally via AuthKit| AuthKit[AuthKit API]
-
-%% gRPC requests
- API_Controller -->|gRPC call| GRPC_Service[gRPC Service]
- GRPC_Service -->|gRPC call to other microservice| AnotherService_GRPC[Another gRPC Service]
-
-%% Responses
- GRPC_Service -->|Return result| API_Controller
- AnotherService_GRPC -->|Return result| GRPC_Service
- API_Controller -->|Return response| SDK
-
-%% Styling
- class Dev,SDK,AuthKit token;
- class API_Controller,GRPC_Service service;
- class AnotherService_GRPC internal;
+This starts AuthKit (REST on `5000`, gRPC on `5001`), a PostgreSQL database, and Keycloak. Configuration is provided via `.env` (see `.env.example`).
+
+### Run locally
+
+```bash
+dotnet build AuthKit.slnx
+dotnet run --project src/Host/Host.csproj
```
-## Contributing
+The host listens on the address configured in `Server:Host` (default `http://0.0.0.0:8080`, HTTP/2).
-We welcome contributions that improve contract clarity, expand integration patterns, or enhance type safety.
+## Documentation
-### Development Guidelines
+| Topic | Link |
+| --- | --- |
+| Documentation index | [Docs](Docs/README.md) |
+| Schemas & Diagrams | [Schemas](Docs/Schemas.md) |
+
+## Contributing
-1. **Branch Strategy** — Create feature branches from `main`
- ```bash
- git checkout -b feature/token-policies
- ```
-2. **Versioning** — Use semantic versioning (MAJOR.MINOR.PATCH)
- - MAJOR: Breaking changes
- - MINOR: New contracts (backward compatible)
- - PATCH: Bug fixes, documentation
+We welcome contributions that improve contract clarity, expand integration patterns, or enhance type safety.
-3. **Pull Request** — Provide clear descriptions of changes and impact
+Please read the [Contributing Guide](CONTRIBUTING.md) for setup, workflow, and pull request guidelines.
## License
**MIT License + Commons Clause**
-The RyzeSpace.Contracts library is open source for personal, educational, and research purposes. Commercial use requires explicit permission.
+AuthKit is open source for personal, educational, and research purposes. Commercial use requires explicit permission.
### Permitted Use ✓
@@ -161,22 +162,10 @@ The RyzeSpace.Contracts library is open source for personal, educational, and re
See [LICENSE](LICENSE) for complete terms.
----
-
-
-
-### Part of the RyzeSpace Ecosystem
-
-**Democratizing access to computational resources through decentralized sharing**
-
-*Every idle GPU, every spare CPU cycle — unlocking potential in the RyzeSpace network*
-
-
-
-**[Documentation](https://docs.ryzespace.com)** • **[Platform](https://ryzespace.com)** • **[Community](https://discord.gg/JsQx8cQ5yp)**
+## Code of Conduct
-
+Please read and follow the [Code of Conduct](CODE_OF_CONDUCT.md) when participating in this project.
-Built with precision by the RyzeSpace team
+## Security
-
\ No newline at end of file
+For vulnerability reporting, see the [Security Policy](SECURITY.md).
diff --git a/RyzeSDK.AuthKit.sln b/RyzeSDK.AuthKit.sln
deleted file mode 100644
index 581b3f5..0000000
--- a/RyzeSDK.AuthKit.sln
+++ /dev/null
@@ -1,36 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{149D2A8E-3A09-4F95-A38E-269C0F425BCB}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{B979757A-4A8D-4C83-B231-521000A955C3}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Host", "Host\Host.csproj", "{58EDDF51-DD85-4A87-92CF-BBAC50262996}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{76085586-8F87-4012-AFC5-69EDF057E6B6}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {149D2A8E-3A09-4F95-A38E-269C0F425BCB}.Release|Any CPU.Build.0 = Release|Any CPU
- {B979757A-4A8D-4C83-B231-521000A955C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B979757A-4A8D-4C83-B231-521000A955C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B979757A-4A8D-4C83-B231-521000A955C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B979757A-4A8D-4C83-B231-521000A955C3}.Release|Any CPU.Build.0 = Release|Any CPU
- {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {58EDDF51-DD85-4A87-92CF-BBAC50262996}.Release|Any CPU.Build.0 = Release|Any CPU
- {76085586-8F87-4012-AFC5-69EDF057E6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {76085586-8F87-4012-AFC5-69EDF057E6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {76085586-8F87-4012-AFC5-69EDF057E6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {76085586-8F87-4012-AFC5-69EDF057E6B6}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- EndGlobalSection
-EndGlobal
diff --git a/RyzeSDK.AuthKit.sln.DotSettings.user b/RyzeSDK.AuthKit.sln.DotSettings.user
deleted file mode 100644
index 5fe9564..0000000
--- a/RyzeSDK.AuthKit.sln.DotSettings.user
+++ /dev/null
@@ -1,75 +0,0 @@
-
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- ForceIncluded
- <AssemblyExplorer>
- <Assembly Path="C:\Users\Lunix\.nuget\packages\keycloak.authservices.sdk\2.7.0\lib\net8.0\Keycloak.AuthServices.Sdk.dll" />
- <Assembly Path="C:\Users\Lunix\.nuget\packages\keycloak.netcore.client\1.0.2\lib\net8.0\NETCore.Keycloak.Client.dll" />
- <Assembly Path="C:\Users\Lunix\.nuget\packages\system.commandline\2.0.0\lib\net8.0\System.CommandLine.dll" />
- <Assembly Path="C:\Users\Lunix\.nuget\packages\commandlineparser\2.9.1\lib\netstandard2.0\CommandLine.dll" />
-</AssemblyExplorer>
\ No newline at end of file
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..8dd1061
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,125 @@
+# Security Policy
+
+AuthKit provides developer authentication, SDK token issuance, token validation, and access enforcement. Because these components directly participate in authentication and authorization, security issues affecting them may have a significant impact on applications integrating AuthKit.
+
+Token issuance, storage, validation, session handling, and authorization logic are considered security-sensitive components.
+
+> [!IMPORTANT]
+> **Do not publicly disclose security vulnerabilities or exploit details.**
+> Please report security issues privately so they can be investigated and addressed before public disclosure.
+
+> [!WARNING]
+> AuthKit is under active development. The security model and implementation are still being hardened, and breaking changes may be introduced as security improvements are made.
+
+## Supported Versions
+
+AuthKit is currently in active development.
+
+Security fixes are applied to the `main` branch first. Where appropriate, security fixes may also be backported to supported stable releases.
+
+Because AuthKit is under active development, users should generally run the latest available version or commit when possible.
+
+## Reporting a Vulnerability
+
+Please report suspected security vulnerabilities directly to the maintainers through a private communication channel.
+
+A useful report should include:
+
+* a clear description of the vulnerability
+* affected component or functionality
+* steps required to reproduce the issue
+* security impact and potential attack scenario
+* affected version, release, or commit
+* proof of concept, if available
+* any known prerequisites or limitations
+
+Please avoid including credentials, private keys, real access tokens, personal data, or other sensitive information in the report.
+
+### Public Issues
+
+If no private reporting channel is currently available, open a minimal public issue **without exploit details or a working proof of concept**.
+
+The issue should only indicate that a potential security vulnerability exists and request private follow-up from the maintainers.
+
+## Response Process
+
+The maintainers aim to:
+
+1. acknowledge receipt of a security report within **7 days**
+2. reproduce and validate the reported issue
+3. assess its severity and potential impact
+4. determine affected versions and components
+5. prepare and implement an appropriate fix
+6. verify the fix through testing and security review where appropriate
+7. coordinate disclosure after a fix or mitigation is available
+
+Response times may vary depending on the complexity and severity of the issue.
+
+## Scope
+
+This security policy covers vulnerabilities affecting AuthKit components, including:
+
+* AuthKit REST API
+* AuthKit gRPC API
+* developer authentication
+* SDK token issuance
+* token validation and verification
+* token storage and handling
+* token revocation and lifecycle management
+* authorization and access enforcement
+* role and permission checks
+* Keycloak integration
+* session handling
+* authentication and authorization middleware
+* security-sensitive configuration and key management
+
+Issues in dependencies may also be considered when AuthKit's integration or configuration introduces or materially contributes to the vulnerability.
+
+## Out of Scope
+
+The following are generally considered out of scope unless they result in a demonstrable security impact:
+
+* formatting, style, or documentation issues
+* theoretical vulnerabilities without a reproducible attack path
+* vulnerabilities requiring unrealistic or unavailable assumptions
+* issues in unsupported environments
+* denial-of-service caused solely by intentionally exhausting resources available to the attacker
+* reports that only describe best-practice improvements without demonstrating a security impact
+
+Out of scope issues may still be considered at the maintainers' discretion.
+
+## Severity
+
+Security reports are evaluated based on factors such as:
+
+* whether authentication can be bypassed
+* whether authorization can be bypassed
+* whether tokens can be forged, stolen, or replayed
+* whether sensitive credentials or cryptographic material can be exposed
+* the privileges required to exploit the issue
+* whether user or system interaction is required
+* the potential confidentiality, integrity, and availability impact
+
+The maintainers may use established severity frameworks such as **CVSS** when appropriate.
+
+## Disclosure
+
+Please allow the maintainers reasonable time to investigate, reproduce, and remediate a reported vulnerability before publicly disclosing technical details.
+
+After a fix or effective mitigation is available, the maintainers may coordinate public disclosure with the reporter.
+
+Disclosure timing may depend on:
+
+* severity and exploitability
+* availability of a fix or mitigation
+* affected versions
+* whether the vulnerability is already being actively exploited
+* coordination with affected users or downstream projects
+
+The maintainers reserve the right to delay disclosure when immediate publication could materially increase risk to users.
+
+## Security Updates
+
+Security fixes may be included in regular releases or published as dedicated security releases when appropriate.
+
+Users are encouraged to keep AuthKit and its security-sensitive dependencies up to date.
diff --git a/appicon.png b/appicon.png
deleted file mode 100644
index bab370c..0000000
Binary files a/appicon.png and /dev/null differ
diff --git a/banners.png b/banners.png
deleted file mode 100644
index 1a78ce7..0000000
Binary files a/banners.png and /dev/null differ
diff --git a/docker-compose.yml b/docker-compose.yml
index a5f402f..7990af4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -62,6 +62,7 @@ services:
start
--http-enabled=true
--hostname-strict=false
+ --import-realm
environment:
KC_FEATURES: scripts
KC_BOOTSTRAP_ADMIN_USERNAME: admin
@@ -70,9 +71,8 @@ services:
KC_DB_URL: jdbc:postgresql://keycloak-db:5432/Auth
KC_DB_USERNAME: postgres
KC_DB_PASSWORD: postgres
- KEYCLOAK_IMPORT: /tmp/keycloak/realm-authz.json
volumes:
- - ./keycloak:/tmp/keycloak
+ - ./keycloak/realm-authz.json:/opt/keycloak/data/import/realm-authz.json:ro
ports:
- "8081:8080"
depends_on:
diff --git a/Application/Application.csproj b/src/Core/Core.csproj
similarity index 54%
rename from Application/Application.csproj
rename to src/Core/Core.csproj
index 18eace3..6cdb7dc 100644
--- a/Application/Application.csproj
+++ b/src/Core/Core.csproj
@@ -6,16 +6,13 @@
enable
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
\ No newline at end of file
+
diff --git a/src/Core/DomainException.cs b/src/Core/DomainException.cs
new file mode 100644
index 0000000..69dbb7b
--- /dev/null
+++ b/src/Core/DomainException.cs
@@ -0,0 +1,10 @@
+namespace Core;
+
+///
+/// Represents the base exception type for domain specific errors.
+///
+///
+/// Domain exceptions describe business rule violations or other errors
+/// originating from the application domain.
+///
+public abstract class DomainException(string message) : Exception(message);
\ No newline at end of file
diff --git a/Application/ErrorResponse.cs b/src/Core/ErrorResponse.cs
similarity index 96%
rename from Application/ErrorResponse.cs
rename to src/Core/ErrorResponse.cs
index 70ef4e0..189946c 100644
--- a/Application/ErrorResponse.cs
+++ b/src/Core/ErrorResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace Application;
+namespace Core;
///
/// Standardized error response DTO for RESTful endpoints.
diff --git a/src/Core/KeyManagement/DTO/KeyEntry.cs b/src/Core/KeyManagement/DTO/KeyEntry.cs
new file mode 100644
index 0000000..9edb64d
--- /dev/null
+++ b/src/Core/KeyManagement/DTO/KeyEntry.cs
@@ -0,0 +1,56 @@
+using Microsoft.IdentityModel.Tokens;
+
+namespace Core.KeyManagement.DTO;
+
+///
+/// Represents single RSA key entry used for JWT signing.
+///
+///
+///
+/// A key entry combines the RSA security key used for cryptographic operations,
+/// the signing credentials used for JWT issuance, associated key metadata,
+/// and the public RSA parameters required for JWKS representation.
+///
+///
+/// The RSA modulus and exponent are exposed as encoded strings so that the
+/// public key material can be published through a JWKS endpoint without
+/// exposing private RSA parameters.
+///
+///
+public sealed record KeyEntry
+{
+ ///
+ /// Gets the RSA security key used for JWT signing operations.
+ ///
+ public required RsaSecurityKey Key { get; init; }
+
+ ///
+ /// Gets the signing credentials used when issuing JWTs with this key.
+ ///
+ public required SigningCredentials Signing { get; init; }
+
+ ///
+ /// Gets the metadata associated with the RSA key.
+ ///
+ public required KeyMetadata Meta { get; init; }
+
+ ///
+ /// Gets the Base64URL-encoded RSA modulus.
+ ///
+ ///
+ /// The modulus corresponds to the n parameter defined by the
+ /// JSON Web Key (JWK) specification and is used when exporting the
+ /// public key as JWKS.
+ ///
+ public required string N { get; init; }
+
+ ///
+ /// Gets the Base64URL-encoded RSA public exponent.
+ ///
+ ///
+ /// The exponent corresponds to the e parameter defined by the
+ /// JSON Web Key (JWK) specification and is used when exporting the
+ /// public key as JWKS.
+ ///
+ public required string E { get; init; }
+}
\ No newline at end of file
diff --git a/src/Core/KeyManagement/DTO/KeyMetadata.cs b/src/Core/KeyManagement/DTO/KeyMetadata.cs
new file mode 100644
index 0000000..48d0b2e
--- /dev/null
+++ b/src/Core/KeyManagement/DTO/KeyMetadata.cs
@@ -0,0 +1,55 @@
+namespace Core.KeyManagement.DTO;
+
+///
+/// Represents metadata describing cryptographic signing key.
+///
+///
+///
+/// The metadata identifies signing key and describes its lifecycle,
+/// cryptographic algorithm, and intended purpose.
+///
+///
+/// This information is used to manage key rotation, revocation, and
+/// publication of public key metadata through JWKS.
+///
+///
+public sealed record KeyMetadata
+{
+ ///
+ /// Gets the unique key identifier (kid).
+ ///
+ ///
+ /// The identifier is used to associate JWT with the public key
+ /// required to validate its signature.
+ ///
+ public required string Kid { get; init; }
+
+ ///
+ /// Gets the date and time at which the key was created.
+ ///
+ public required DateTimeOffset CreatedAt { get; init; }
+
+ ///
+ /// Gets value indicating whether the key has been revoked.
+ ///
+ ///
+ /// A revoked key should no longer be used for signing new tokens.
+ /// Depending on the key management policy, its public material may
+ /// remain available for validating tokens issued before revocation.
+ ///
+ public bool Revoked { get; init; }
+
+ ///
+ /// Gets the cryptographic algorithm used by the key.
+ ///
+ /// Defaults to RS256.
+ public string Algorithm { get; init; } = "RS256";
+
+ ///
+ /// Gets the intended purpose of the key.
+ ///
+ ///
+ /// Defaults to JWT signing.
+ ///
+ public string Purpose { get; init; } = "JWT signing";
+}
\ No newline at end of file
diff --git a/src/Core/KeyManagement/DTO/KeystoreOnDisk.cs b/src/Core/KeyManagement/DTO/KeystoreOnDisk.cs
new file mode 100644
index 0000000..43f688c
--- /dev/null
+++ b/src/Core/KeyManagement/DTO/KeystoreOnDisk.cs
@@ -0,0 +1,32 @@
+namespace Core.KeyManagement.DTO;
+
+///
+/// Represents serialized keystore persisted on disk before encryption.
+///
+///
+///
+/// The keystore contains the RSA key records used for JWT signing together
+/// with the identifier of the key currently active for issuing new tokens.
+///
+///
+/// The serialized representation is intended for persistence and is encrypted
+/// before being written to disk by the key management infrastructure.
+///
+///
+public sealed record KeystoreOnDisk
+{
+ ///
+ /// Gets the identifier of the key currently active for signing new tokens.
+ ///
+ /// The value corresponds to the Kid of one of the records
+ public required string ActiveKid { get; init; }
+
+ ///
+ /// Gets the RSA key records contained in the keystore.
+ ///
+ ///
+ /// The collection contains the persisted representation of all keys
+ /// managed by the key store, including active, inactive, and revoked keys.
+ ///
+ public required List Records { get; init; }
+}
\ No newline at end of file
diff --git a/src/Core/KeyManagement/DTO/KeystoreRecordOnDisk.cs b/src/Core/KeyManagement/DTO/KeystoreRecordOnDisk.cs
new file mode 100644
index 0000000..20a2393
--- /dev/null
+++ b/src/Core/KeyManagement/DTO/KeystoreRecordOnDisk.cs
@@ -0,0 +1,32 @@
+namespace Core.KeyManagement.DTO;
+
+///
+/// Represents single stored RSA key entry within the serialized keystore.
+///
+///
+///
+/// The record contains the metadata required to identify and manage the key
+/// together with its private RSA key material in serialized representation.
+///
+///
+/// The private key is encoded as Base64 string for persistence and should
+/// only be handled by the key management infrastructure. It must not be
+/// exposed through public key endpoints such as JWKS.
+///
+///
+public sealed record KeystoreRecordOnDisk
+{
+ ///
+ /// Gets the metadata associated with the stored RSA key.
+ ///
+ public required KeyMetadata Metadata { get; init; }
+
+ ///
+ /// Gets the Base64 encoded private RSA key material.
+ ///
+ ///
+ /// This value contains sensitive cryptographic material and must be
+ /// protected from unauthorized access.
+ ///
+ public required string PrivateKeyBase64 { get; init; }
+}
\ No newline at end of file
diff --git a/Application/Features/KeyManagement/DTO/PublicJwkDto.cs b/src/Core/KeyManagement/DTO/PublicJwkDto.cs
similarity index 81%
rename from Application/Features/KeyManagement/DTO/PublicJwkDto.cs
rename to src/Core/KeyManagement/DTO/PublicJwkDto.cs
index 32bbf7c..bf57e3c 100644
--- a/Application/Features/KeyManagement/DTO/PublicJwkDto.cs
+++ b/src/Core/KeyManagement/DTO/PublicJwkDto.cs
@@ -1,9 +1,9 @@
using System.Text.Json.Serialization;
-namespace Application.Features.KeyManagement.DTO;
+namespace Core.KeyManagement.DTO;
///
-/// Represents a public JSON Web Key (JWK) exposed in the JWKS endpoint.
+/// Represents public JSON Web Key (JWK) exposed in the JWKS endpoint.
///
///
///
@@ -22,7 +22,7 @@ public record PublicJwkDto(
[property: JsonPropertyName("x5c")] string[] X5c
) {
///
- /// Initializes a new with standard defaults (RSA/RS256).
+ /// Initializes new with standard defaults (RSA/RS256).
///
public PublicJwkDto()
: this("RSA", "sig", string.Empty, "RS256", string.Empty, string.Empty, []) {}
diff --git a/src/Core/KeyManagement/Entity/SigningKey.cs b/src/Core/KeyManagement/Entity/SigningKey.cs
new file mode 100644
index 0000000..01b1ff6
--- /dev/null
+++ b/src/Core/KeyManagement/Entity/SigningKey.cs
@@ -0,0 +1,151 @@
+namespace Core.KeyManagement.Entity;
+
+///
+/// Represents cryptographic signing key managed by the key management system.
+///
+///
+///
+/// A signing key contains both its public representation and protected private
+/// key material together with lifecycle information used to determine whether
+/// the key may be used to sign new tokens.
+///
+///
+/// The key lifecycle is represented by its activation, validity, expiration,
+/// and revocation state. State transitions are performed through the methods
+/// exposed by this entity.
+///
+///
+public sealed record SigningKey
+{
+ ///
+ /// Gets the unique identifier of the signing key.
+ ///
+ ///
+ /// This identifier is used as the JWT kid value to associate a
+ /// token with the public key required to verify its signature.
+ ///
+ public required Guid Id { get; init; }
+
+ ///
+ /// Gets the public RSA key encoded as PEM.
+ ///
+ ///
+ /// This value contains public key material and may be exposed to
+ /// consumers through the appropriate public key representation.
+ ///
+ public required string PublicKeyPem { get; init; }
+
+ ///
+ /// Gets the encrypted private key material encoded for persistence.
+ ///
+ ///
+ /// The value contains sensitive cryptographic material and must be
+ /// protected from unauthorized access.
+ ///
+ public required string PrivateKeyEncrypted { get; init; }
+
+ ///
+ /// Gets the cryptographic algorithm used when signing tokens.
+ ///
+ /// For RSA signing keys this will typically be an algorithm such as RS256.
+ public required string Algorithm { get; init; }
+
+ ///
+ /// Gets the date and time at which the signing key was created.
+ ///
+ public required DateTime CreatedAt { get; init; }
+
+ ///
+ /// Gets the date and time before which the key must not be used for signing.
+ ///
+ ///
+ /// null when the key has no activation restriction.
+ ///
+ public DateTime? NotBefore { get; init; }
+
+ ///
+ /// Gets the date and time after which the key must no longer be used for signing.
+ ///
+ ///
+ /// null when the key does not have an expiration time.
+ ///
+ public DateTime? ExpiresAt { get; init; }
+
+ ///
+ /// Gets the date and time at which the key was revoked.
+ ///
+ ///
+ /// null when the key has not been revoked.
+ ///
+ public DateTime? RevokedAt { get; init; }
+
+ ///
+ /// Gets value indicating whether the key is currently active.
+ ///
+ public bool IsActive { get; init; }
+
+ ///
+ /// Determines whether the signing key is currently valid for signing tokens.
+ ///
+ ///
+ /// The current date and time used to evaluate the key lifecycle.
+ ///
+ ///
+ /// true when the key is active, has not been revoked, is past its
+ /// activation time, and has not expired; otherwise, false.
+ ///
+ public bool IsValidForSigning(DateTime now)
+ => IsActive &&
+ RevokedAt is null &&
+ (NotBefore is null || now >= NotBefore) &&
+ (ExpiresAt is null || now < ExpiresAt);
+
+ ///
+ /// Activates the signing key.
+ ///
+ /// The date and time at which the key becomes active.
+ /// A new instance representing the activated key.
+ ///
+ /// Activating key sets its timestamp to
+ /// and clears any previous revocation timestamp.
+ ///
+ public SigningKey Activate(DateTime now)
+ => this with
+ {
+ IsActive = true,
+ NotBefore = now,
+ RevokedAt = null
+ };
+
+ ///
+ /// Revokes the signing key.
+ ///
+ /// The date and time at which the key is revoked.
+ /// A new instance representing the revoked key.
+ ///
+ /// A revoked key is immediately marked as inactive and cannot be used
+ /// for signing new tokens.
+ ///
+ public SigningKey Revoke(DateTime now)
+ => this with
+ {
+ IsActive = false,
+ RevokedAt = now
+ };
+
+ ///
+ /// Marks the signing key as expired.
+ ///
+ /// The date and time at which the key expires.
+ /// A new instance representing the expired key.
+ ///
+ /// Expiring key marks it as inactive and sets its expiration timestamp
+ /// to .
+ ///
+ public SigningKey Expire(DateTime now)
+ => this with
+ {
+ IsActive = false,
+ ExpiresAt = now
+ };
+}
diff --git a/src/Core/KeyManagement/Interfaces/IJwtKeyStore.cs b/src/Core/KeyManagement/Interfaces/IJwtKeyStore.cs
new file mode 100644
index 0000000..ecb4dc8
--- /dev/null
+++ b/src/Core/KeyManagement/Interfaces/IJwtKeyStore.cs
@@ -0,0 +1,114 @@
+using Core.KeyManagement.DTO;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Core.KeyManagement.Interfaces;
+
+///
+/// Defines the contract for managing JWT signing keys and their lifecycle.
+///
+///
+///
+/// The key store maintains the signing keys used by AuthKit to issue and
+/// validate JSON Web Tokens.
+///
+///
+/// It provides access to the active signing credentials, lookup of keys
+/// by their key identifier (kid), public key discovery through JWKS,
+/// and lifecycle operations such as rotation and revocation.
+///
+///
+/// Implementations are responsible for securely storing and managing
+/// private key material and for ensuring that revoked or otherwise invalid
+/// keys are not used to sign new tokens.
+///
+///
+public interface IJwtKeyStore
+{
+ ///
+ /// Initializes the key store and loads the persisted signing key state.
+ ///
+ ///
+ /// This method should be called before the key store is used for signing,
+ /// validation, rotation, or key discovery operations.
+ ///
+ Task InitializeAsync();
+
+ ///
+ /// Gets the signing credentials for the key currently active for JWT issuance.
+ ///
+ /// The active instance.
+ ///
+ /// Thrown when no valid active signing key is available.
+ ///
+ SigningCredentials GetActiveSigningCredentials();
+
+ ///
+ /// Gets the signing credentials associated with a specific key identifier.
+ ///
+ /// The unique key identifier kid).
+ ///
+ /// The matching , or null when
+ /// no key with the specified identifier exists.
+ ///
+ SigningCredentials? GetSigningCredentialsByKid(string kid);
+
+ ///
+ /// Gets the public signing keys in JWKS-compatible representation.
+ ///
+ ///
+ /// An enumerable collection of containing
+ /// the public key material required to verify JWT signatures.
+ ///
+ ///
+ /// The returned collection must contain public key material only.
+ /// Private key material must never be exposed through this method.
+ ///
+ IEnumerable GetPublicJwks();
+
+ ///
+ /// Rotates the active RSA signing key.
+ ///
+ /// The RSA key size in bits. Defaults to 4096.
+ ///
+ /// A task representing the asynchronous rotation operation and containing
+ /// metadata for the newly generated signing key.
+ ///
+ ///
+ ///
+ /// Rotation creates a new signing key and makes it the active key for
+ /// issuing new JWTs.
+ ///
+ ///
+ /// Previously active keys may remain available for signature validation
+ /// so that tokens issued before rotation can continue to be validated,
+ /// subject to the key lifecycle and retention policy.
+ ///
+ ///
+ Task RotateAsync(int rsaBits = 4096);
+
+ ///
+ /// Revokes the signing key associated with specific key identifier.
+ ///
+ /// The unique key identifier (kid) of the key to revoke.
+ ///
+ /// A task representing the asynchronous revocation operation and
+ /// containing true when the key was successfully revoked;
+ /// otherwise, false.
+ ///
+ ///
+ /// A revoked key must not be used to sign new JWTs.
+ /// Implementations may retain the public key for validation of
+ /// previously issued tokens according to their key retention policy.
+ ///
+ Task RevokeAsync(string kid);
+
+ ///
+ /// Gets metadata associated with specific key identifier.
+ ///
+ /// The unique key identifier (kid).
+ ///
+ /// The corresponding , or null when
+ /// no key with the specified identifier exists.
+ ///
+ KeyMetadata? GetMetadata(string kid);
+}
diff --git a/src/Core/KeyManagement/Interfaces/IKeyEncryptor.cs b/src/Core/KeyManagement/Interfaces/IKeyEncryptor.cs
new file mode 100644
index 0000000..f8cd1cc
--- /dev/null
+++ b/src/Core/KeyManagement/Interfaces/IKeyEncryptor.cs
@@ -0,0 +1,47 @@
+namespace Core.KeyManagement.Interfaces;
+
+///
+/// Defines contract for encrypting and decrypting sensitive data.
+///
+///
+///
+/// Implementations provide reversible protection for sensitive data stored
+/// at rest, such as private cryptographic key material, configuration
+/// secrets, or tokens.
+///
+///
+/// The encryption mechanism and key management strategy are implementation
+/// details of the concrete encryptor. The contract guarantees only that data
+/// encrypted by can subsequently be restored using
+/// with the corresponding protected data.
+///
+///
+/// Implementations should use authenticated encryption or another mechanism
+/// that provides integrity protection in addition to confidentiality.
+///
+///
+public interface IKeyEncryptor
+{
+ ///
+ /// Encrypts plaintext into protected binary representation.
+ ///
+ /// The plaintext string to encrypt.
+ /// A byte array containing the encrypted representation of the plaintext.
+ ///
+ /// Implementations are responsible for selecting an appropriate encoding
+ /// for the plaintext and for including any metadata required to decrypt
+ /// the resulting ciphertext.
+ ///
+ byte[] Encrypt(string plaintext);
+
+ ///
+ /// Decrypts encrypted data and restores its original plaintext representation.
+ ///
+ /// The encrypted binary representation produced by .
+ /// The decrypted plaintext string.
+ ///
+ /// The method should reject ciphertext that is malformed, corrupted,
+ /// tampered with, or cannot be decrypted using the configured key.
+ ///
+ string Decrypt(byte[] ciphertext);
+}
diff --git a/src/Core/KeyManagement/Interfaces/IKeyGenerator.cs b/src/Core/KeyManagement/Interfaces/IKeyGenerator.cs
new file mode 100644
index 0000000..03fe2c3
--- /dev/null
+++ b/src/Core/KeyManagement/Interfaces/IKeyGenerator.cs
@@ -0,0 +1,34 @@
+using Core.KeyManagement.DTO;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Core.KeyManagement.Interfaces;
+
+///
+/// Defines contract for generating RSA key pairs used by the JWT key management system.
+///
+///
+///
+/// generate RSA key pairs together with the metadata required
+/// to identify and manage the generated keys throughout their lifecycle.
+/// keys can be used during initial key store provisioning, key
+/// rotation, or other cryptographic key management operations.
+/// The RSA key size can be configured by the caller. Implementations should
+/// enforce any minimum or maximum key size required by their security policy.
+///
+///
+public interface IKeyGenerator
+{
+ ///
+ /// Generates new RSA key pair and its associated metadata.
+ ///
+ /// The RSA key size in bits. Defaults to 4096.
+ ///
+ /// A tuple containing the generated and
+ /// its associated .
+ ///
+ ///
+ /// The generated key should contain both public and private key material
+ /// and must be protected appropriately when persisted.
+ ///
+ (RsaSecurityKey Key, KeyMetadata Meta) Generate(int rsaBits = 4096);
+}
diff --git a/src/Core/KeyManagement/Interfaces/IKeyStoreRepository.cs b/src/Core/KeyManagement/Interfaces/IKeyStoreRepository.cs
new file mode 100644
index 0000000..38d852a
--- /dev/null
+++ b/src/Core/KeyManagement/Interfaces/IKeyStoreRepository.cs
@@ -0,0 +1,43 @@
+namespace Core.KeyManagement.Interfaces;
+
+///
+/// Defines an abstraction for persisting and loading protected keystore data.
+///
+///
+///
+/// The repository is responsible only for persistence. It does not perform
+/// encryption or decryption of keystore contents.
+///
+/// Keystore data should be encrypted by before
+/// being passed to and decrypted after being returned
+/// by .
+///
+///
+public interface IKeyStoreRepository
+{
+ ///
+ /// Loads the persisted keystore data.
+ ///
+ ///
+ /// The returned data is expected to contain the encrypted representation
+ /// produced by the configured .
+ ///
+ ///
+ /// A containing the encrypted keystore data.
+ /// Returns an empty memory region when no persisted keystore exists.
+ ///
+ Task> LoadAsync();
+
+ ///
+ /// Persists encrypted keystore data.
+ ///
+ ///
+ /// The repository stores the provided data as-is and does not perform
+ /// encryption itself.
+ ///
+ /// The encrypted keystore data to persist.
+ ///
+ /// A task representing the asynchronous persistence operation.
+ ///
+ Task SaveAsync(ReadOnlyMemory data);
+}
diff --git a/src/Core/KeyManagement/Services/AesKeyEncryptor.cs b/src/Core/KeyManagement/Services/AesKeyEncryptor.cs
new file mode 100644
index 0000000..39cbec7
--- /dev/null
+++ b/src/Core/KeyManagement/Services/AesKeyEncryptor.cs
@@ -0,0 +1,126 @@
+using System.Security.Cryptography;
+using System.Text;
+using Core.KeyManagement.Interfaces;
+
+namespace Core.KeyManagement.Services;
+
+///
+/// Provides AES256-CBC encryption and decryption for persisted keystore data.
+///
+///
+///
+/// The encryptor derives its symmetric encryption key from Base64 encoded
+/// 256 bit master key supplied during construction.
+///
+/// Each encryption operation generates cryptographically secure random
+/// initialization vector (IV). The IV is prefixed to the ciphertext so that
+/// it can be recovered during decryption.
+///
+///
+/// AES-CBC provides confidentiality but does not provide authenticated
+/// integrity protection. The encrypted representation must therefore be
+/// protected against tampering by another mechanism, or this implementation
+/// should be replaced with an authenticated encryption mode such as AES-GCM.
+///
+///
+public sealed class AesKeyEncryptor : IKeyEncryptor
+{
+ private readonly byte[] _key;
+
+ ///
+ /// Initializes new instance of the class.
+ ///
+ /// Base64 encoded 256 bit AES key. The decoded value must contain exactly 32 bytes.
+ /// Thrown when is not a valid Base64 string.
+ /// Thrown when the decoded key does not contain exactly 32 bytes.
+ public AesKeyEncryptor(string masterKeyBase64)
+ {
+ _key = Convert.FromBase64String(masterKeyBase64);
+
+ if (_key.Length != 32)
+ {
+ throw new InvalidOperationException(
+ "Master key must be exactly 32 bytes (256 bits) when decoded from Base64.");
+ }
+ }
+
+ ///
+ /// Encrypts plaintext using AES-256-CBC with a randomly generated IV.
+ ///
+ /// The UTF-8 text to encrypt.
+ ///
+ /// A byte array containing the randomly generated IV followed by the
+ /// encrypted ciphertext.
+ ///
+ public byte[] Encrypt(string plaintext)
+ {
+ using var aes = Aes.Create();
+
+ aes.Key = _key;
+ aes.GenerateIV();
+
+ var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
+ var ciphertext = Transform(aes.CreateEncryptor(), plaintextBytes);
+
+ return Combine(aes.IV, ciphertext);
+ }
+
+ ///
+ /// Decrypts an AES256-CBC encrypted payload.
+ ///
+ ///
+ /// A byte array containing the IV followed by the ciphertext produced
+ /// by .
+ ///
+ /// The decrypted UTF-8 plaintext.
+ /// Thrown when the encrypted payload does not contain a complete IV.
+ /// Thrown when the ciphertext cannot be decrypted using the configured key.
+ public string Decrypt(byte[] blob)
+ {
+ using var aes = Aes.Create();
+
+ aes.Key = _key;
+
+ var ivLength = aes.BlockSize / 8;
+
+ if (blob.Length < ivLength)
+ {
+ throw new ArgumentException(
+ "Encrypted payload does not contain a complete initialization vector.",
+ nameof(blob));
+ }
+
+ var iv = blob[..ivLength];
+ var ciphertext = blob[ivLength..];
+
+ aes.IV = iv;
+
+ var plaintextBytes = Transform(
+ aes.CreateDecryptor(),
+ ciphertext);
+
+ return Encoding.UTF8.GetString(plaintextBytes);
+ }
+
+ private static byte[] Transform(
+ ICryptoTransform transform,
+ byte[] data) =>
+ transform.TransformFinalBlock(data, 0, data.Length);
+
+ private static byte[] Combine(
+ byte[] first,
+ byte[] second)
+ {
+ var result = new byte[first.Length + second.Length];
+
+ Buffer.BlockCopy(first, 0, result, 0, first.Length);
+ Buffer.BlockCopy(
+ second,
+ 0,
+ result,
+ first.Length,
+ second.Length);
+
+ return result;
+ }
+}
diff --git a/src/Core/KeyManagement/Services/JwtKeyStore.cs b/src/Core/KeyManagement/Services/JwtKeyStore.cs
new file mode 100644
index 0000000..1a28883
--- /dev/null
+++ b/src/Core/KeyManagement/Services/JwtKeyStore.cs
@@ -0,0 +1,378 @@
+using System.Buffers;
+using System.Buffers.Text;
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Core.KeyManagement.DTO;
+using Core.KeyManagement.Interfaces;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Core.KeyManagement.Services;
+
+///
+/// Manages RSA signing keys used for JWT issuance and signature verification.
+///
+///
+///
+/// Maintains RSA signing keys in memory together with their metadata,
+/// signing credentials, and public JWK representation.
+///
+/// Supports key store initialization, key rotation, key revocation, signing
+/// key lookup, and JWKS public key discovery.
+///
+///
+public sealed class JwtKeyStore(
+ IKeyStoreRepository repository,
+ IKeyEncryptor encryptor,
+ IKeyGenerator generator)
+ : IJwtKeyStore, IAsyncDisposable
+{
+ private readonly ConcurrentDictionary _keys = new();
+ private volatile string? _activeKid;
+ private volatile PublicJwkDto[]? _cachedJwks;
+ private int _disposed;
+
+ ///
+ /// Initializes the key store by loading persisted encrypted key material.
+ ///
+ ///
+ /// If no persisted key store exists, new RSA signing key is generated
+ /// and persisted automatically.
+ ///
+ public async Task InitializeAsync()
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ var encrypted = await repository.LoadAsync().ConfigureAwait(false);
+
+ if (encrypted.Length == 0)
+ {
+ await RotateAsync().ConfigureAwait(false);
+ return;
+ }
+
+ var decrypted = encryptor.Decrypt(encrypted.ToArray());
+
+ var keystore = JsonSerializer.Deserialize(decrypted)
+ ?? throw new InvalidOperationException(
+ "The persisted keystore could not be deserialized.");
+
+ foreach (var record in keystore.Records)
+ {
+ var rsa = RSA.Create();
+
+ try
+ {
+ rsa.ImportRSAPrivateKey(
+ Convert.FromBase64String(record.PrivateKeyBase64),
+ out _);
+
+ var key = new RsaSecurityKey(rsa)
+ {
+ KeyId = record.Metadata.Kid
+ };
+
+ var publicParameters = rsa.ExportParameters(false);
+
+ var entry = new KeyEntry
+ {
+ Key = key,
+ Signing = new SigningCredentials(
+ key,
+ SecurityAlgorithms.RsaSha256),
+ Meta = record.Metadata,
+ N = Base64UrlEncode(publicParameters.Modulus!),
+ E = Base64UrlEncode(publicParameters.Exponent!)
+ };
+
+ _keys[record.Metadata.Kid] = entry;
+ }
+ catch
+ {
+ rsa.Dispose();
+ throw;
+ }
+ }
+
+ if (!_keys.ContainsKey(keystore.ActiveKid))
+ {
+ throw new InvalidOperationException(
+ $"The persisted keystore references unknown active key '{keystore.ActiveKid}'.");
+ }
+
+ _activeKid = keystore.ActiveKid;
+ RebuildJwksCache();
+ }
+
+ ///
+ /// Retrieves the signing credentials associated with the currently active key.
+ ///
+ /// The used to sign newly issued JWTs.
+ /// Thrown when no active signing key is available or the active key has been revoked.
+ public SigningCredentials GetActiveSigningCredentials()
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ var activeKid = _activeKid;
+
+ if (string.IsNullOrWhiteSpace(activeKid) ||
+ !_keys.TryGetValue(activeKid, out var entry))
+ {
+ throw new InvalidOperationException(
+ "No active signing key is available.");
+ }
+
+ if (entry.Meta.Revoked)
+ {
+ throw new InvalidOperationException(
+ $"The active signing key '{activeKid}' has been revoked.");
+ }
+
+ return entry.Signing;
+ }
+
+ ///
+ /// Retrieves signing credentials associated with specific key identifier.
+ ///
+ /// The unique key identifier.
+ ///
+ /// The matching , or null when
+ /// the key does not exist or has been revoked.
+ ///
+ public SigningCredentials? GetSigningCredentialsByKid(string kid)
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ return _keys.TryGetValue(kid, out var entry) &&
+ !entry.Meta.Revoked
+ ? entry.Signing
+ : null;
+ }
+
+ ///
+ /// Returns the public signing keys exposed by the key store in JWKS-compatible format.
+ ///
+ ///
+ /// A collection of instances representing
+ /// all non revoked public signing keys.
+ ///
+ public IEnumerable GetPublicJwks()
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ var cache = _cachedJwks;
+
+ if (cache is not null)
+ return cache;
+
+ RebuildJwksCache();
+
+ return _cachedJwks!;
+ }
+
+ private void RebuildJwksCache()
+ {
+ var keys = _keys.Values
+ .Where(entry => !entry.Meta.Revoked)
+ .Select(entry => new PublicJwkDto
+ {
+ Kty = "RSA",
+ Use = "sig",
+ Kid = entry.Meta.Kid,
+ Alg = entry.Meta.Algorithm,
+ N = entry.N,
+ E = entry.E
+ })
+ .ToArray();
+
+ _cachedJwks = keys;
+ }
+
+ ///
+ /// Generates and activates a new RSA signing key.
+ ///
+ /// The RSA key size in bits. Defaults to 4096 bits.
+ /// Metadata describing the newly generated signing key.
+ public async Task RotateAsync(int rsaBits = 4096)
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ var (key, metadata) = generator.Generate(rsaBits);
+
+ key.KeyId ??= metadata.Kid;
+
+ var publicParameters = key.Rsa.ExportParameters(false);
+
+ var entry = new KeyEntry
+ {
+ Key = key,
+ Signing = new SigningCredentials(
+ key,
+ SecurityAlgorithms.RsaSha256),
+ Meta = metadata,
+ N = Base64UrlEncode(publicParameters.Modulus!),
+ E = Base64UrlEncode(publicParameters.Exponent!)
+ };
+
+ _keys[metadata.Kid] = entry;
+ _activeKid = metadata.Kid;
+ _cachedJwks = null;
+
+ await PersistAsync().ConfigureAwait(false);
+
+ return metadata;
+ }
+
+ ///
+ /// Revokes the signing key associated with the specified key identifier.
+ ///
+ ///
+ /// The unique key identifier of the key to revoke.
+ ///
+ ///
+ /// true when the key was found and revoked; otherwise, false.
+ ///
+ ///
+ /// Thrown when attempting to revoke the currently active signing key.
+ ///
+ public async Task RevokeAsync(string kid)
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ if (!_keys.TryGetValue(kid, out var entry))
+ return false;
+
+ if (string.Equals(_activeKid, kid, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "The active signing key cannot be revoked.");
+ }
+
+ _keys[kid] = entry with
+ {
+ Meta = entry.Meta with
+ {
+ Revoked = true
+ }
+ };
+
+ _cachedJwks = null;
+
+ await PersistAsync().ConfigureAwait(false);
+
+ return true;
+ }
+
+ ///
+ /// Retrieves metadata associated with a specific signing key.
+ ///
+ /// The unique key identifier.
+ ///
+ /// The associated with the specified key,
+ /// or null when the key does not exist.
+ ///
+ public KeyMetadata? GetMetadata(string kid)
+ {
+ ObjectDisposedException.ThrowIf(
+ Volatile.Read(ref _disposed) != 0,
+ this);
+
+ return _keys.TryGetValue(kid, out var entry)
+ ? entry.Meta
+ : null;
+ }
+
+ private async Task PersistAsync()
+ {
+ var records = _keys.Values.Select(entry => new KeystoreRecordOnDisk
+ {
+ Metadata = entry.Meta,
+ PrivateKeyBase64 = Convert.ToBase64String(
+ entry.Key.Rsa.ExportRSAPrivateKey())
+ })
+ .ToList();
+
+ var keystore = new KeystoreOnDisk
+ {
+ ActiveKid = _activeKid
+ ?? throw new InvalidOperationException(
+ "Cannot persist a keystore without an active key."),
+ Records = records
+ };
+
+ var json = JsonSerializer.Serialize(keystore);
+ var encrypted = encryptor.Encrypt(json);
+
+ await repository
+ .SaveAsync(encrypted)
+ .ConfigureAwait(false);
+ }
+
+ private static string Base64UrlEncode(byte[] input)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+
+ Span buffer = stackalloc byte[
+ Base64.GetMaxEncodedToUtf8Length(input.Length)];
+
+ var status = Base64.EncodeToUtf8(
+ input,
+ buffer,
+ out _,
+ out var bytesWritten);
+
+ if (status != OperationStatus.Done)
+ {
+ throw new InvalidOperationException(
+ $"Base64 encoding failed: {status}");
+ }
+
+ for (var i = 0; i < bytesWritten; i++)
+ {
+ buffer[i] = buffer[i] switch
+ {
+ (byte)'+' => (byte)'-',
+ (byte)'/' => (byte)'_',
+ _ => buffer[i]
+ };
+ }
+
+ var end = bytesWritten;
+
+ while (end > 0 && buffer[end - 1] == (byte)'=')
+ end--;
+
+ return Encoding.ASCII.GetString(buffer[..end]);
+ }
+
+ ///
+ /// Releases all cryptographic resources owned by the key store.
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ return ValueTask.CompletedTask;
+
+ foreach (var entry in _keys.Values)
+ entry.Key.Rsa.Dispose();
+
+ _keys.Clear();
+ _cachedJwks = null;
+ _activeKid = null;
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Core/KeyManagement/Services/RsaKeyGenerator.cs b/src/Core/KeyManagement/Services/RsaKeyGenerator.cs
new file mode 100644
index 0000000..b7dcc18
--- /dev/null
+++ b/src/Core/KeyManagement/Services/RsaKeyGenerator.cs
@@ -0,0 +1,50 @@
+using System.Security.Cryptography;
+using Core.KeyManagement.DTO;
+using Core.KeyManagement.Interfaces;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Core.KeyManagement.Services;
+
+///
+/// Generates RSA key pairs with unique identifiers and associated metadata.
+///
+///
+///
+/// Uses RSA keys for JWT signing and generates a unique key identifier (KID)
+/// for every newly created key.
+///
+/// The generated retains the RSA private key
+/// material and can therefore be used for signing and secure persistence.
+/// The default key size is 4096 bits and can be configured when generating a new key.
+///
+///
+public sealed class RsaKeyGenerator : IKeyGenerator
+{
+ ///
+ /// Generates new RSA key pair and its associated metadata.
+ ///
+ /// The RSA key size in bits. Defaults to 4096 bits.
+ ///
+ /// A tuple containing the generated and
+ /// its associated .
+ ///
+ public (RsaSecurityKey Key, KeyMetadata Meta) Generate(int rsaBits = 4096)
+ {
+ var rsa = RSA.Create(rsaBits);
+ var kid = Guid.NewGuid().ToString("N");
+
+ var key = new RsaSecurityKey(rsa)
+ {
+ KeyId = kid
+ };
+
+ var meta = new KeyMetadata
+ {
+ Kid = kid,
+ CreatedAt = DateTimeOffset.UtcNow,
+ Revoked = false
+ };
+
+ return (key, meta);
+ }
+}
diff --git a/src/Core/Options/ErrorMetadataOptions.cs b/src/Core/Options/ErrorMetadataOptions.cs
new file mode 100644
index 0000000..3bd2ecf
--- /dev/null
+++ b/src/Core/Options/ErrorMetadataOptions.cs
@@ -0,0 +1,12 @@
+namespace Core.Options;
+
+///
+/// Represents configuration options for error documentation metadata.
+///
+public sealed record ErrorMetadataOptions
+{
+ ///
+ /// Gets the base URL used to reference error documentation.
+ ///
+ public string DocsBaseUrl { get; init; } = "https://localhost:8080/errors";
+}
\ No newline at end of file
diff --git a/Domain/Features/TokenKeyBindings/IKeyBindingRepository.cs b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingRepository.cs
similarity index 77%
rename from Domain/Features/TokenKeyBindings/IKeyBindingRepository.cs
rename to src/Core/TokenKeyBindings/Interfaces/IKeyBindingRepository.cs
index d83eb63..037a0bb 100644
--- a/Domain/Features/TokenKeyBindings/IKeyBindingRepository.cs
+++ b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingRepository.cs
@@ -1,14 +1,17 @@
-namespace Domain.Features.TokenKeyBindings;
+using Core.TokenKeyBindings.Services;
+
+namespace Core.TokenKeyBindings.Interfaces;
///
-/// Repository abstraction for managing entities.
+/// Defines repository abstraction for persisting and retrieving
+/// entities.
///
///
-///
-/// - Adds, retrieves, updates, and lists key bindings for developer tokens.
-/// - Supports queries by token ID and signing key ID.
-/// - Designed for asynchronous persistence operations in a DDD context.
-///
+///
+/// Provides asynchronous persistence operations for token to signing key bindings.
+/// Supports adding, retrieving, updating, and listing bindings associated
+/// with developer tokens and signing keys.
+///
///
public interface IKeyBindingRepository
{
@@ -39,4 +42,4 @@ public interface IKeyBindingRepository
/// The unique identifier of the developer token.
/// A collection of entities.
Task> ListByTokenAsync(Guid tokenId);
-}
\ No newline at end of file
+}
diff --git a/src/Core/TokenKeyBindings/Interfaces/IKeyBindingService.cs b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingService.cs
new file mode 100644
index 0000000..38e0baa
--- /dev/null
+++ b/src/Core/TokenKeyBindings/Interfaces/IKeyBindingService.cs
@@ -0,0 +1,95 @@
+using Core.TokenKeyBindings.Services;
+
+namespace Core.TokenKeyBindings.Interfaces;
+
+///
+/// Defines contract for managing bindings between developer tokens and RSA signing keys.
+///
+///
+///
+/// Provides operations for creating, updating, rebinding, retrieving, listing,
+/// and revoking token to key bindings.
+///
+/// Key bindings associate developer token with specific signing key and
+/// its corresponding public key material.
+///
+///
+public interface IKeyBindingService
+{
+ ///
+ /// Creates new key binding for developer token.
+ ///
+ /// The unique identifier of the developer token.
+ /// The unique identifier of the signing key to bind.
+ /// The public key associated with the signing key.
+ /// The newly created .
+ Task CreateBindingAsync(
+ Guid tokenId,
+ string signingKeyId,
+ string publicKey);
+
+ ///
+ /// Rebinds an existing key binding to different signing key.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key currently associated with the binding.
+ /// The identifier of the new signing key.
+ /// The public key associated with the new signing key.
+ ///
+ /// The updated , or null if the
+ /// existing binding could not be found.
+ ///
+ Task RebindAsync(
+ Guid tokenId,
+ string signingKeyId,
+ string newSigningKeyId,
+ string newPublicKey);
+
+ ///
+ /// Updates the public key associated with an existing key binding.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key whose public key is being updated.
+ /// The new public key associated with the signing key.
+ ///
+ /// The updated , or null if the
+ /// binding could not be found.
+ ///
+ Task UpdatePublicKeyAsync(
+ Guid tokenId,
+ string signingKeyId,
+ string newPublicKey);
+
+ ///
+ /// Revokes all key bindings associated with developer token.
+ ///
+ /// The unique identifier of the developer token.
+ ///
+ /// true if one or more bindings were successfully revoked;
+ /// otherwise, false.
+ ///
+ Task RevokeAsync(Guid tokenId);
+
+ ///
+ /// Lists all key bindings associated with a developer token.
+ ///
+ /// The unique identifier of the developer token.
+ ///
+ /// A collection of entities associated
+ /// with the specified developer token.
+ ///
+ Task> ListBindingsAsync(Guid tokenId);
+
+ ///
+ /// Retrieves specific key binding for developer token and signing key.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key to retrieve.
+ ///
+ /// The matching , or null if no
+ /// matching binding exists.
+ ///
+ Task GetBindingAsync(
+ Guid tokenId,
+ string signingKeyId);
+}
diff --git a/src/Core/TokenKeyBindings/Services/KeyBindingService.cs b/src/Core/TokenKeyBindings/Services/KeyBindingService.cs
new file mode 100644
index 0000000..1c9dece
--- /dev/null
+++ b/src/Core/TokenKeyBindings/Services/KeyBindingService.cs
@@ -0,0 +1,150 @@
+using Core.TokenKeyBindings.Interfaces;
+
+namespace Core.TokenKeyBindings.Services;
+
+///
+/// Provides application level operations for managing bindings between
+/// developer tokens and RSA signing keys.
+///
+///
+///
+/// Coordinates key binding operations through
+/// and applies the domain behavior exposed by .
+///
+///
+public sealed class KeyBindingService(IKeyBindingRepository repository) : IKeyBindingService
+{
+ ///
+ /// Creates new key binding for the specified developer token.
+ ///
+ /// The unique identifier of the developer token.
+ /// The unique identifier of the RSA signing key to bind.
+ /// The public key associated with the signing key.
+ /// The newly persisted .
+ public Task CreateBindingAsync(
+ Guid tokenId,
+ string signingKeyId,
+ string publicKey)
+ {
+ var binding = new TokenKeyBinding(
+ tokenId,
+ signingKeyId,
+ publicKey);
+
+ return repository.AddAsync(binding);
+ }
+
+ ///
+ /// Rebinds an existing token key binding to a different signing key.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key currently associated with the binding.
+ /// The identifier of the new signing key.
+ /// The public key associated with the new signing key.
+ ///
+ /// The updated , or null if the
+ /// existing binding could not be found.
+ ///
+ public async Task RebindAsync(
+ Guid tokenId,
+ string signingKeyId,
+ string newSigningKeyId,
+ string newPublicKey)
+ {
+ var binding = await repository.GetAsync(
+ tokenId,
+ signingKeyId);
+
+ if (binding is null)
+ return null;
+
+ var updated = binding.Rebind(
+ newSigningKeyId,
+ newPublicKey);
+
+ await repository.UpdateAsync(updated);
+
+ return updated;
+ }
+
+ ///
+ /// Updates the public key of an existing token key binding.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key associated with the binding.
+ /// The new public key associated with the signing key.
+ ///
+ /// The updated , or null if the
+ /// binding could not be found.
+ ///
+ public async Task UpdatePublicKeyAsync(
+ Guid tokenId,
+ string signingKeyId,
+ string newPublicKey)
+ {
+ var binding = await repository.GetAsync(
+ tokenId,
+ signingKeyId);
+
+ if (binding is null)
+ return null;
+
+ var updated = binding.UpdatePublicKey(newPublicKey);
+
+ await repository.UpdateAsync(updated);
+ return updated;
+ }
+
+ ///
+ /// Revokes all active key bindings associated with a developer token.
+ ///
+ /// The unique identifier of the developer token.
+ ///
+ /// true if at least one binding was revoked; otherwise,
+ /// false when no active bindings were found.
+ ///
+ public async Task RevokeAsync(Guid tokenId)
+ {
+ var bindings = await repository.ListByTokenAsync(tokenId);
+ var anyUpdated = false;
+
+ foreach (var binding in bindings)
+ {
+ if (binding.Revoked)
+ continue;
+
+ var revoked = binding.Revoke();
+
+ await repository.UpdateAsync(revoked);
+
+ anyUpdated = true;
+ }
+
+ return anyUpdated;
+ }
+
+ ///
+ /// Retrieves all key bindings associated with a developer token.
+ ///
+ ///
+ /// The unique identifier of the developer token.
+ ///
+ ///
+ /// A collection of entities associated
+ /// with the specified token.
+ ///
+ public Task> ListBindingsAsync(Guid tokenId) =>
+ repository.ListByTokenAsync(tokenId);
+
+ ///
+ /// Retrieves specific key binding for developer token and signing key.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key to retrieve.
+ ///
+ /// The matching , or null if no
+ /// matching binding exists.
+ ///
+ public Task GetBindingAsync(Guid tokenId, string signingKeyId) =>
+ repository.GetAsync(tokenId, signingKeyId);
+}
\ No newline at end of file
diff --git a/src/Core/TokenKeyBindings/Services/TokenKeyBinding.cs b/src/Core/TokenKeyBindings/Services/TokenKeyBinding.cs
new file mode 100644
index 0000000..11a80ef
--- /dev/null
+++ b/src/Core/TokenKeyBindings/Services/TokenKeyBinding.cs
@@ -0,0 +1,105 @@
+namespace Core.TokenKeyBindings.Services;
+
+///
+/// Represents binding between developer token and the RSA signing key
+/// used to sign its JWTs.
+///
+///
+///
+/// Each binding associates a developer token with a specific signing key
+/// identified by .
+/// The binding stores the corresponding public key used for signature
+/// verification and supports key rotation, public key updates, and revocation.
+///
+///
+public sealed record TokenKeyBinding
+{
+ ///
+ /// Gets the unique identifier of the developer token associated with this binding.
+ ///
+ public Guid TokenId { get; init; }
+
+ ///
+ /// Gets the unique identifier of the RSA signing key associated with this binding.
+ ///
+ public string SigningKeyId { get; private set; } = null!;
+
+ ///
+ /// Gets the public key associated with the signing key.
+ ///
+ public string PublicKey { get; private set; } = null!;
+
+ ///
+ /// Gets the date and time at which the current key binding was established
+ /// or last changed.
+ ///
+ public DateTimeOffset BoundAt { get; private set; }
+
+ ///
+ /// Gets value indicating whether this key binding has been revoked.
+ ///
+ public bool Revoked { get; private set; }
+
+ ///
+ /// Initializes new instance of the record.
+ ///
+ /// The unique identifier of the developer token.
+ /// The unique identifier of the RSA signing key.
+ /// The public key associated with the signing key.
+ ///
+ /// Thrown when or
+ /// is null.
+ ///
+ public TokenKeyBinding(
+ Guid tokenId,
+ string signingKeyId,
+ string publicKey)
+ {
+ TokenId = tokenId;
+ SigningKeyId = signingKeyId
+ ?? throw new ArgumentNullException(nameof(signingKeyId));
+ PublicKey = publicKey
+ ?? throw new ArgumentNullException(nameof(publicKey));
+ BoundAt = DateTimeOffset.UtcNow;
+ }
+
+ ///
+ /// Creates a new binding state associated with a different RSA signing key.
+ ///
+ /// The unique identifier of the new signing key.
+ /// The public key associated with the new signing key.
+ /// containing the new signing key and public key information.
+ public TokenKeyBinding Rebind(
+ string newSigningKeyId,
+ string newPublicKey)
+ => this with
+ {
+ SigningKeyId = newSigningKeyId,
+ PublicKey = newPublicKey,
+ BoundAt = DateTimeOffset.UtcNow
+ };
+
+ ///
+ /// Creates a new binding state with an updated public key.
+ ///
+ /// The new public key associated with the current signing key.
+ /// A new containing the updated public key.
+ public TokenKeyBinding UpdatePublicKey(string updatedPublicKey)
+ => this with
+ {
+ PublicKey = updatedPublicKey,
+ BoundAt = DateTimeOffset.UtcNow
+ };
+
+ ///
+ /// Creates a new binding state with the binding marked as revoked.
+ ///
+ /// A new
+ /// with set to true.
+ public TokenKeyBinding Revoke()
+ => this with
+ {
+ Revoked = true,
+ BoundAt = DateTimeOffset.UtcNow
+ };
+}
\ No newline at end of file
diff --git a/src/Host/Cli/AuthKitServerOptions.cs b/src/Host/Cli/AuthKitServerOptions.cs
new file mode 100644
index 0000000..8ec6467
--- /dev/null
+++ b/src/Host/Cli/AuthKitServerOptions.cs
@@ -0,0 +1,65 @@
+namespace Host.Cli;
+
+///
+/// Represents configuration options for the AuthKit server.
+///
+///
+///
+/// Contains the network endpoint, token issuer, storage configuration,
+/// and logging configuration used by the server.
+///
+///
+public sealed class AuthKitServerOptions
+{
+ ///
+ /// Gets or sets the HTTP endpoint on which the AuthKit server listens to.
+ ///
+ public string Host { get; set; } = "http://0.0.0.0:7070";
+
+ ///
+ /// Gets or sets the issuer identifier used by AuthKit when issuing tokens.
+ ///
+ public string Issuer { get; set; } = "authkit.local";
+
+ ///
+ /// Gets or sets the storage configuration used by the AuthKit server.
+ ///
+ public StorageOptions Storage { get; set; } = new();
+
+ ///
+ /// Gets or sets the logging configuration used by the AuthKit server.
+ ///
+ public LoggingOptions Logging { get; set; } = new();
+
+ ///
+ /// Represents configuration options for AuthKit server persistence.
+ ///
+ public sealed class StorageOptions
+ {
+ ///
+ /// Gets or sets the storage provider used to persist AuthKit data.
+ ///
+ public string Provider { get; set; } = "marten";
+
+ ///
+ /// Gets or sets the connection string used by the configured storage provider.
+ ///
+ public string ConnectionString { get; set; } = "";
+ }
+
+ ///
+ /// Represents logging configuration options for the AuthKit server.
+ ///
+ public sealed class LoggingOptions
+ {
+ ///
+ /// Gets or sets value indicating whether server logging is enabled.
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Gets or sets the minimum logging level.
+ ///
+ public string Level { get; set; } = "Information";
+ }
+}
\ No newline at end of file
diff --git a/src/Host/Cli/ServerHost.cs b/src/Host/Cli/ServerHost.cs
new file mode 100644
index 0000000..52cd45b
--- /dev/null
+++ b/src/Host/Cli/ServerHost.cs
@@ -0,0 +1,102 @@
+using Host.Plugins;
+using Microsoft.Extensions.Options;
+using Spectre.Console;
+
+namespace Host.Cli;
+
+///
+/// Background service responsible for displaying AuthKit server startup information
+/// and maintaining the server host lifetime.
+///
+///
+///
+/// Displays the configured environment, listening endpoint, ports, issuer,
+/// and loaded plugin information when the server starts.
+/// The service remains active until the host requests shutdown through the
+/// provided .
+///
+///
+public sealed class ServerHost(
+ IOptions opts,
+ IHostEnvironment env,
+ IReadOnlyList plugins) : BackgroundService
+{
+ private readonly AuthKitServerOptions _opts = opts.Value;
+
+ ///
+ /// Executes the server host background service.
+ ///
+ /// Token that is signaled when the host is shutting down.
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await Task.Yield();
+
+ AnsiConsole.Write(new FigletText("AuthKit")
+ .Color(Color.Green)
+ .Centered());
+
+ var restPort = Environment.GetEnvironmentVariable("DEV_CERT_PORT_REST") ?? "5000";
+ var grpcPort = Environment.GetEnvironmentVariable("DEV_CERT_PORT_GRPC") ?? "5001";
+
+ var info = new Panel(
+ $"""
+ [bold blue]Environment:[/] {env.EnvironmentName}
+ [bold blue]Listening on:[/] {_opts.Host}
+ [bold blue]REST port:[/] {restPort} [bold blue]gRPC port:[/] {grpcPort}
+ [bold yellow]Issuer:[/] {_opts.Issuer}
+ [grey]Started at {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz}[/]
+ """)
+ {
+ Border = BoxBorder.Double,
+ Padding = new Padding(1, 1),
+ Header = new PanelHeader("Server Started")
+ };
+ AnsiConsole.Write(info);
+
+ var table = new Table().Border(TableBorder.Rounded);
+ table.AddColumn("[green]Plugin[/]");
+ table.AddColumn("[yellow]Version[/]");
+ table.AddColumn("[grey]Description[/]");
+ table.AddColumn("[cyan]Middleware[/]");
+ table.AddColumn("[magenta]Security schemes[/]");
+
+ if (plugins.Count == 0)
+ {
+ table.AddRow("[grey]none loaded[/]", "-", "-", "-", "-");
+ }
+ else
+ {
+ foreach (var lp in plugins)
+ {
+ var description = lp.Plugin.Description ?? "-";
+ var middleware = lp.Plugin.MiddlewareType?.Name ?? "-";
+ var schemes = lp.Plugin.GetSecuritySchemes();
+ var schemeNames = schemes.Count > 0 ? string.Join(", ", schemes.Keys) : "-";
+
+ table.AddRow(
+ $"[bold]{lp.Plugin.Name}[/]",
+ $"v{lp.Plugin.Version}",
+ description,
+ middleware,
+ schemeNames);
+ }
+ }
+
+ AnsiConsole.Write(new Panel(table)
+ {
+ Border = BoxBorder.Rounded,
+ Header = new PanelHeader($"Plugins ({plugins.Count})")
+ });
+
+ try
+ {
+ await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected during shutdown.
+ }
+
+ AnsiConsole.MarkupLine("[red]Server shutting down...[/]");
+ }
+}
diff --git a/src/Host/Configuration/AppMiddlewareConfiguration.cs b/src/Host/Configuration/AppMiddlewareConfiguration.cs
new file mode 100644
index 0000000..ad8d632
--- /dev/null
+++ b/src/Host/Configuration/AppMiddlewareConfiguration.cs
@@ -0,0 +1,58 @@
+using Host.Plugins;
+using Host.Restful.Middleware.Exceptions;
+
+namespace Host.Configuration;
+
+///
+/// Provides extension methods for configuring the application's HTTP middleware
+/// pipeline.
+///
+///
+///
+/// Configures routing, validation and exception handling, plugin-provided
+/// middleware, authentication, authorization, and development-only API
+/// documentation middleware.
+///
+///
+/// Plugin middleware is inserted after the host exception handling middleware
+/// and before authentication so plugins can participate in request processing
+/// before the authenticated endpoint pipeline is reached.
+///
+///
+public static class AppMiddlewareConfiguration
+{
+ ///
+ /// Configures the applications HTTP middleware pipeline.
+ ///
+ ///
+ /// The plugins loaded during application startup. Plugins may optionally
+ /// contribute middleware through their configured middleware type.
+ ///
+ /// The configured instance.
+ public static WebApplication ConfigureMiddleware(
+ this WebApplication app,
+ IReadOnlyList plugins)
+ {
+ app.UseRouting();
+
+ app.UseMiddleware();
+ app.UseMiddleware();
+
+ foreach (var plugin in plugins)
+ {
+ if (plugin.Plugin.MiddlewareType is { } middlewareType)
+ app.UseMiddleware(middlewareType);
+ }
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ if (!app.Environment.IsDevelopment())
+ return app;
+
+ app.UseSwagger();
+ app.UseSwaggerUI();
+
+ return app;
+ }
+}
diff --git a/src/Host/Configuration/ApplicationInitialization.cs b/src/Host/Configuration/ApplicationInitialization.cs
new file mode 100644
index 0000000..ac645f0
--- /dev/null
+++ b/src/Host/Configuration/ApplicationInitialization.cs
@@ -0,0 +1,120 @@
+using System.Reflection;
+using Core.KeyManagement.Services;
+using Core.Options;
+using Core.KeyManagement.Entity;
+using FluentValidation;
+using Host.Plugins;
+using Host.KeyManagement.Repositories;
+using Host.ServiceDiscovery;
+using Host.TokenKeyBindings.Repositories;
+
+namespace Host.Configuration;
+
+///
+/// Provides application service registration and dependency injection configuration.
+///
+///
+///
+/// Configures logging, options, HTTP context access, MVC controllers, service
+/// discovery, FluentValidation validators, and application-specific options.
+/// Controllers contributed by dynamically loaded plugins are registered as MVC
+/// application parts during application initialization.
+///
+///
+/// Service discovery scans the relevant Host and Core assemblies while excluding
+/// infrastructure services that require explicit registration or configuration.
+///
+///
+public static class ApplicationInitialization
+{
+ ///
+ /// Configures application services and registers dependencies with the
+ /// dependency injection container.
+ ///
+ /// The service collection used to register application dependencies.
+ /// The application configuration used to configure registered servicesand options.
+ ///
+ /// The plugins loaded during application startup. Each plugin assembly is
+ /// registered as an MVC application part to expose its controllers.
+ ///
+ /// The configured instance.
+ public static IServiceCollection ConfigureApp(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ IReadOnlyList plugins)
+ {
+ services.AddLogging(logging =>
+ {
+ logging.AddConsole();
+ logging.AddConfiguration(configuration.GetSection("Logging"));
+ });
+
+ services.AddOptions();
+ services.AddHttpContextAccessor();
+
+ var mvcBuilder = services.AddControllers();
+ foreach (var lp in plugins)
+ mvcBuilder.AddApplicationPart(lp.Assembly);
+
+ var discoveryLogger = LoggerFactory.Create(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(LogLevel.Debug);
+ }).CreateLogger("ServiceDiscovery");
+
+ services.AddDiscoveredServices(GetRelevantAssemblies(), opts =>
+ {
+ configuration.GetSection("ServiceDiscovery").Bind(opts);
+
+ opts.ExcludedTypes.Add(typeof(AesKeyEncryptor));
+ opts.ExcludedTypes.Add(typeof(RsaKeyGenerator));
+ opts.ExcludedTypes.Add(typeof(JwtKeyStore));
+ opts.ExcludedTypes.Add(typeof(KeyStoreRepository));
+ opts.ExcludedTypes.Add(typeof(InMemoryKeyBindingRepository));
+ }, discoveryLogger)
+ .AddFluentValidation();
+
+ services.ConfigureAppOptions(configuration);
+
+ return services;
+ }
+
+ #region Private Options FluentValidation
+
+ extension(IServiceCollection services)
+ {
+ ///
+ /// Registers application option classes and binds them to their
+ /// corresponding configuration sections.
+ ///
+ /// The application configuration containing option values.
+ private void ConfigureAppOptions(IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("ErrorMetadata"));
+ }
+
+ ///
+ /// Scans the relevant application assemblies and registers all
+ /// FluentValidation validators with a scoped lifetime.
+ ///
+ private void AddFluentValidation()
+ {
+ services.Scan(scan => scan
+ .FromAssemblies(GetRelevantAssemblies())
+ .AddClasses(classes => classes.AssignableTo(typeof(AbstractValidator<>)))
+ .AsImplementedInterfaces()
+ .WithScopedLifetime());
+ }
+ }
+
+ #endregion
+
+ #region Private Helpers
+ private static Assembly[] GetRelevantAssemblies() =>
+ [.. new[]
+ {
+ typeof(ApplicationInitialization).Assembly, // Host
+ typeof(SigningKey).Assembly // Core
+ }.Distinct()];
+ #endregion
+}
diff --git a/src/Host/Configuration/AuthKitConfiguration.cs b/src/Host/Configuration/AuthKitConfiguration.cs
new file mode 100644
index 0000000..dcac060
--- /dev/null
+++ b/src/Host/Configuration/AuthKitConfiguration.cs
@@ -0,0 +1,75 @@
+using Core.KeyManagement.Interfaces;
+using Core.KeyManagement.Services;
+using Core.TokenKeyBindings.Interfaces;
+using Core.TokenKeyBindings.Services;
+using Host.KeyManagement.Repositories;
+using Host.KeyManagement.Security;
+using Host.Restful.Middleware.Exceptions;
+using Host.TokenKeyBindings.Repositories;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Host.Configuration;
+
+///
+/// Provides dependency injection configuration for AuthKit core services.
+///
+///
+///
+/// Registers plugin-independent services used by AuthKit for JWT signing key
+/// management and token-to-signing-key bindings.
+///
+///
+/// These services can be shared by multiple authentication plugins that need
+/// to issue, sign, validate, or associate tokens with cryptographic keys.
+///
+///
+/// The configuration also registers a custom authorization middleware result
+/// handler responsible for producing consistent authorization responses.
+///
+///
+public static class AuthKitConfiguration
+{
+ ///
+ /// Registers AuthKit core services with the dependency injection container.
+ ///
+ /// The service collection used to register AuthKit dependencies.
+ ///
+ ///
+ /// Registers the HTTP context accessor, authorization result handler, JWT
+ /// signing key infrastructure, encrypted key store services, and token key
+ /// binding services.
+ ///
+ ///
+ /// The AES master key used to protect persisted key material is read from the
+ /// Encryption:AES_MASTER_KEY configuration value.
+ ///
+ ///
+ public static void AddAuthKitCore(
+ this IServiceCollection services)
+ {
+ services.AddHttpContextAccessor();
+
+ services.AddSingleton<
+ IAuthorizationMiddlewareResultHandler,
+ CustomAuthorizationMiddlewareResultHandler>();
+
+ services.AddSingleton(sp =>
+ {
+ var configuration = sp.GetRequiredService();
+
+ var key = configuration["Encryption:AES_MASTER_KEY"]
+ ?? throw new InvalidOperationException(
+ "Missing AES_MASTER_KEY in configuration.");
+
+ return new AesKeyEncryptor(key);
+ });
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddHostedService();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+}
diff --git a/Host/Configuration/EndpointConfiguration.cs b/src/Host/Configuration/EndpointConfiguration.cs
similarity index 50%
rename from Host/Configuration/EndpointConfiguration.cs
rename to src/Host/Configuration/EndpointConfiguration.cs
index 6f44a26..b40387f 100644
--- a/Host/Configuration/EndpointConfiguration.cs
+++ b/src/Host/Configuration/EndpointConfiguration.cs
@@ -1,25 +1,28 @@
using System.Diagnostics;
+using Core.KeyManagement.Interfaces;
+using Host.Plugins;
namespace Host.Configuration;
///
-/// Provides extension methods to map application endpoints, including REST, gRPC, health checks, and metrics.
+/// Provides extension methods for mapping AuthKit application endpoints.
///
///
-///
-/// - Maps root ("/") endpoint to a JSON object describing the service, version, environment, and available endpoints.
-/// - Maps controller routes via MapControllers.
-/// - Maps health check endpoint ("/health") for monitoring service availability.
-/// - Maps metrics endpoint ("/metrics") returning service uptime in seconds.
-///
+///
+/// Configures the root service information endpoint, controller routes,
+/// health monitoring, and basic process uptime metrics.
+///
+///
+/// The health endpoint verifies the availability of the JWT key store and
+/// executes health checks contributed by loaded authentication plugins.
+///
///
public static class EndpointConfiguration
{
///
- /// Maps application-specific endpoints to the provided .
+ /// Maps AuthKit application endpoints to the specified web application.
///
- /// The to configure endpoints for.
- /// The configured instance for method chaining.
+ /// The configured instance.
public static WebApplication MapAppEndpoints(this WebApplication app)
{
app.MapGet("/", () => Results.Json(new
@@ -44,14 +47,31 @@ public static WebApplication MapAppEndpoints(this WebApplication app)
}));
app.MapControllers();
- app.MapGet("/health", () => Results.Ok(new { status = "Healthy", time = DateTime.UtcNow }))
+ app.MapGet("/health", async (HttpContext context, IJwtKeyStore keyStore, IReadOnlyList plugins) =>
+ {
+ var keyStoreHealthy = keyStore.GetPublicJwks().Any();
+
+ var pluginResults = new Dictionary();
+ foreach (var lp in plugins)
+ pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync(context.RequestServices);
+
+ var healthy = keyStoreHealthy && pluginResults.Values.All(ok => ok);
+
+ return Results.Json(new
+ {
+ status = healthy ? "Healthy" : "Unhealthy",
+ time = DateTime.UtcNow,
+ jwtKeyStore = keyStoreHealthy ? "Healthy" : "Unhealthy",
+ plugins = pluginResults
+ }, statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
+ })
.WithName("HealthCheck")
.WithTags("Monitoring");
app.MapGet("/metrics", () => Results.Json(new { uptime = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalSeconds }))
.WithName("Metrics")
.WithTags("Monitoring");
-
+
return app;
}
-}
\ No newline at end of file
+}
diff --git a/src/Host/Configuration/EventHandlerRegistrationFactory.cs b/src/Host/Configuration/EventHandlerRegistrationFactory.cs
new file mode 100644
index 0000000..735781d
--- /dev/null
+++ b/src/Host/Configuration/EventHandlerRegistrationFactory.cs
@@ -0,0 +1,38 @@
+using Core.KeyManagement.Services;
+using Host.Plugins;
+using Wolverine;
+
+namespace Host.Configuration;
+
+///
+/// Provides extension methods for registering Wolverine event handlers.
+///
+///
+///
+/// Registers the Core assembly for Wolverine handler discovery and includes
+/// assemblies contributed by dynamically loaded AuthKit plugins.
+///
+///
+/// This allows plugin provided command and query handlers to be discovered
+/// without requiring the Host project to reference plugin assemblies directly.
+///
+///
+public static class EventHandlerRegistrationFactory
+{
+ ///
+ /// Registers application and plugin assemblies for Wolverine handler discovery.
+ ///
+ /// The Wolverine configuration options to modify.
+ ///
+ /// The plugins loaded during application startup whose assemblies may
+ /// contain Wolverine handlers.
+ ///
+ public static void IncludeEventHandlers(
+ this WolverineOptions opts,
+ IReadOnlyList plugins)
+ {
+ opts.Discovery.IncludeAssembly(typeof(JwtKeyStore).Assembly);
+ foreach (var plugin in plugins)
+ opts.Discovery.IncludeAssembly(plugin.Assembly);
+ }
+}
diff --git a/Host/Configuration/GrpcConfiguration.cs b/src/Host/Configuration/GrpcConfiguration.cs
similarity index 56%
rename from Host/Configuration/GrpcConfiguration.cs
rename to src/Host/Configuration/GrpcConfiguration.cs
index 30a4549..6406ac3 100644
--- a/Host/Configuration/GrpcConfiguration.cs
+++ b/src/Host/Configuration/GrpcConfiguration.cs
@@ -1,24 +1,27 @@
-using Host.Services;
+using Host.Grpc;
namespace Host.Configuration;
///
-/// Provides extension methods to configure gRPC services and map gRPC endpoints in the application.
+/// Provides extension methods for configuring gRPC services and endpoints.
///
///
-///
-/// - Registers gRPC services in the DI container.
-/// - Supports adding global interceptors for exception handling or logging (commented placeholder included).
-/// - Maps gRPC service endpoints to the application's request pipeline.
-///
+///
+/// Registers gRPC services with the applications dependency injection
+/// container and maps the available gRPC service implementations to the
+/// request pipeline.
+/// Global gRPC interceptors can be registered through the gRPC configuration
+/// when cross cutting concerns such as exception handling or request logging
+/// are required.
+///
///
public static class GrpcConfiguration
{
///
- /// Adds gRPC services to the .
+ /// Registers gRPC services with the dependency injection container.
///
- /// The to configure.
- /// The configured for method chaining.
+ /// The service collection used to register gRPC services.
+ /// The configured instance.
public static IServiceCollection AddGrpcServices(this IServiceCollection services)
{
services.AddGrpc(options =>
@@ -39,4 +42,4 @@ public static WebApplication MapGrpcEndpoints(this WebApplication app)
app.MapGrpcService();
return app;
}
-}
\ No newline at end of file
+}
diff --git a/Host/Configuration/InfrastructureConfiguration.cs b/src/Host/Configuration/InfrastructureConfiguration.cs
similarity index 63%
rename from Host/Configuration/InfrastructureConfiguration.cs
rename to src/Host/Configuration/InfrastructureConfiguration.cs
index 29b213f..2ab457b 100644
--- a/Host/Configuration/InfrastructureConfiguration.cs
+++ b/src/Host/Configuration/InfrastructureConfiguration.cs
@@ -1,4 +1,4 @@
-using Host.Configuration.Factory;
+using Host.Plugins;
using JasperFx;
using Marten;
using Wolverine;
@@ -8,23 +8,36 @@
namespace Host.Configuration;
///
-/// Provides infrastructure-level configuration for the application.
+/// Provides extension methods for configuring application infrastructure.
///
///
-///
-/// - Registers Wolverine
-///
+///
+/// Configures Wolverine messaging and Marten document persistence used by the
+/// AuthKit host.
+/// Wolverine is configured to discover handlers from the Core assembly and
+/// dynamically loaded plugin assemblies, while FluentValidation is integrated
+/// into message processing.
+///
///
public static class InfrastructureConfiguration
{
+ ///
+ /// Configures Wolverine for the application host.
+ ///
+ /// web application builder used to configure Wolverine and logging.
+ ///
+ /// The plugins loaded during application startup whose assemblies may
+ /// contain Wolverine message handlers.
+ ///
public static void ConfigureWolverine(
- this WebApplicationBuilder builder)
+ this WebApplicationBuilder builder,
+ IReadOnlyList plugins)
{
builder.UseWolverine(opts =>
{
opts.UseFluentValidation();
- opts.IncludeEventHandlers();
-
+ opts.IncludeEventHandlers(plugins);
+
opts.Policies.MessageExecutionLogLevel(LogLevel.None);
opts.Policies.MessageSuccessLogLevel(LogLevel.None);
});
@@ -55,4 +68,4 @@ public static IServiceCollection ConfigureMarten(
return services;
}
-}
\ No newline at end of file
+}
diff --git a/Host/Configuration/KestrelConfiguration.cs b/src/Host/Configuration/KestrelConfiguration.cs
similarity index 56%
rename from Host/Configuration/KestrelConfiguration.cs
rename to src/Host/Configuration/KestrelConfiguration.cs
index baa22be..85470df 100644
--- a/Host/Configuration/KestrelConfiguration.cs
+++ b/src/Host/Configuration/KestrelConfiguration.cs
@@ -3,22 +3,27 @@
namespace Host.Configuration;
///
-/// Configures Kestrel server with HTTPS endpoints and environment-based certificate settings.
+/// Provides configuration helpers for the Kestrel web server.
///
///
-///
-/// - Reads certificate path, password, and REST/gRPC ports from environment variables.
-/// - Falls back to default values for local development if environment variables are missing.
-/// - Validates certificate existence and warns if the password is missing.
-/// - Sets REST endpoint with HTTP/1 + HTTP/2 and gRPC endpoint with HTTP/2 only.
-///
+///
+/// Configures HTTPS listeners for the REST and gRPC endpoints using a
+/// certificate and connection settings supplied through environment variables.
+///
+/// REST supports both HTTP/1.1 and HTTP/2, while the gRPC endpoint is restrictedto HTTP/2.
+/// When configuration environment variables are not provided, development defaults are used.
///
public static class KestrelConfiguration
{
///
- /// Configures Kestrel server with HTTPS listeners for REST and gRPC using the provided certificate settings.
+ /// Configures Kestrel with HTTPS listeners for the REST and gRPC endpoints.
///
- /// The to configure.
+ /// The web host builder used to configure Kestrel.
+ ///
+ /// Reads the certificate path, certificate password, and REST and gRPC ports
+ /// from the corresponding environment variables. Missing values fall back to
+ /// development defaults.
+ ///
public static void ConfigureKestrelServer(this IWebHostBuilder webHost)
{
var (certPath, certPassword, portRest, portGrpc) = LoadSettings();
@@ -32,6 +37,13 @@ public static void ConfigureKestrelServer(this IWebHostBuilder webHost)
#region Helper Methods
+ ///
+ /// Loads Kestrel certificate and endpoint settings from environment variables.
+ ///
+ ///
+ /// A tuple containing the certificate path, certificate password,
+ /// REST port, and gRPC port.
+ ///
private static (string certPath, string certPassword, int portRest, int portGrpc) LoadSettings()
{
var certPath = Environment.GetEnvironmentVariable("DEV_CERT_PATH") ?? "/root/certs/devcert.pfx";
@@ -42,6 +54,12 @@ private static (string certPath, string certPassword, int portRest, int portGrpc
return (certPath, certPassword, portRest, portGrpc);
}
+ ///
+ /// Validates the configured HTTPS certificate settings and writes warnings
+ /// for missing certificate files or passwords.
+ ///
+ /// Path to the HTTPS certificate file.
+ /// Password used to load the certificate.
private static void ValidateCertificate(string certPath, string certPassword)
{
if (!File.Exists(certPath))
@@ -59,11 +77,19 @@ private static void ValidateCertificate(string certPath, string certPassword)
}
}
+ ///
+ /// Configures the Kestrel listeners for REST and gRPC traffic.
+ ///
+ /// The web host builder used to configure Kestrel.
+ /// Path to the HTTPS certificate file.
+ /// Password used to load the HTTPS certificate.
+ /// Port used by the REST endpoint.
+ /// Port used by the gRPC endpoint.
private static void ConfigureListeners(
- IWebHostBuilder webHost,
- string certPath,
- string certPassword,
- int portRest,
+ IWebHostBuilder webHost,
+ string certPath,
+ string certPassword,
+ int portRest,
int portGrpc)
{
webHost.ConfigureKestrel(options =>
@@ -85,4 +111,4 @@ private static void ConfigureListeners(
}
#endregion
-}
\ No newline at end of file
+}
diff --git a/src/Host/Configuration/KeycloakConfiguration.cs b/src/Host/Configuration/KeycloakConfiguration.cs
new file mode 100644
index 0000000..e935e01
--- /dev/null
+++ b/src/Host/Configuration/KeycloakConfiguration.cs
@@ -0,0 +1,120 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Newtonsoft.Json.Linq;
+
+namespace Host.Configuration;
+
+///
+/// Provides configuration for Keycloak-based JWT authentication.
+///
+///
+///
+/// Configures ASP.NET Core JWT bearer authentication using a Keycloak realm
+/// as the token authority.
+/// Keycloak configuration is read from environment variables and defaults to
+/// the local AuthKit development environment when values are not provided.
+///
+///
+/// Keycloak client roles contained in the resource_access claim are
+/// converted into standard claims so they can
+/// be consumed by ASP.NET Core authorization policies.
+///
+///
+public static class KeycloakConfiguration
+{
+ ///
+ /// Registers and configures Keycloak JWT bearer authentication.
+ ///
+ /// The service collection used to register authentication services.
+ ///
+ /// Reads the Keycloak URL, realm, and client identifier from the
+ /// KEYCLOAK_URL, KEYCLOAK_REALM, and
+ /// KEYCLOAK_CLIENT_ID environment variables respectively.
+ ///
+ public static void AddKeycloakServices(this IServiceCollection services)
+ {
+ var baseUrl = Environment.GetEnvironmentVariable("KEYCLOAK_URL") ?? "http://keycloak:8080";
+ var realm = Environment.GetEnvironmentVariable("KEYCLOAK_REALM") ?? "authz";
+ var clientId = Environment.GetEnvironmentVariable("KEYCLOAK_CLIENT_ID") ?? "workspace-authz";
+
+ JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
+
+ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(options =>
+ {
+ options.Authority = $"{baseUrl}/realms/{realm}";
+ options.Audience = clientId;
+ options.RequireHttpsMetadata = false;
+
+ options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuers =
+ [
+ "http://localhost:8081/realms/authz",
+ "http://keycloak:8080/realms/authz"
+ ],
+ ValidateAudience = true,
+ ValidAudience = clientId,
+ NameClaimType = "preferred_username",
+ RoleClaimType = ClaimTypes.Role
+ };
+
+ options.Events = new JwtBearerEvents
+ {
+ OnTokenValidated = context =>
+ {
+ if (context.Principal?.Identity
+ is ClaimsIdentity identity)
+ {
+ AddKeycloakClientRoles(identity);
+ }
+ return Task.CompletedTask;
+ }
+ };
+
+ options.BackchannelHttpHandler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback =
+ HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
+ };
+ });
+ }
+
+ ///
+ /// Extracts roles assigned to the configured Keycloak client from the
+ /// resource_access token claim and adds them as ASP.NET Core role
+ /// claims.
+ ///
+ ///
+ /// The authenticated claims identity to which role claims are added.
+ ///
+ private static void AddKeycloakClientRoles(ClaimsIdentity identity)
+ {
+ var resourceAccessClaim = identity.FindFirst("resource_access")?.Value;
+
+ if (string.IsNullOrWhiteSpace(resourceAccessClaim))
+ return;
+
+ var resourceAccess = JObject.Parse(resourceAccessClaim);
+
+ if (!resourceAccess.TryGetValue(
+ "workspace-authz",
+ out var workspaceClient))
+ {
+ return;
+ }
+
+ var roles = workspaceClient["roles"]?.ToObject();
+
+ if (roles is null)
+ return;
+
+ identity.AddClaims(
+ roles.Select(role =>
+ new Claim(
+ ClaimTypes.Role,
+ role)));
+ }
+}
diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs
new file mode 100644
index 0000000..c020451
--- /dev/null
+++ b/src/Host/Configuration/RestfulConfiguration.cs
@@ -0,0 +1,115 @@
+using AuthKit.Plugins.Abstractions;
+using Host.Plugins;
+using Microsoft.OpenApi.Models;
+
+namespace Host.Configuration;
+
+///
+/// Provides extension methods for configuring AuthKit RESTful services and
+/// OpenAPI/Swagger documentation.
+///
+///
+///
+/// - Registers API explorer services required for endpoint metadata discovery.
+/// - Registers Swagger generation and configures the application's OpenAPI document.
+/// - Registers the built-in JWT bearer security scheme.
+/// - Registers additional security schemes contributed by loaded AuthKit plugins.
+/// - Converts AuthKit security scheme descriptors into OpenAPI security scheme definitions.
+///
+///
+public static class RestfulConfiguration
+{
+ ///
+ /// Registers RESTful API services and configures Swagger/OpenAPI generation.
+ ///
+ /// The to configure.
+ ///
+ /// The plugins loaded by the Host. Each plugin may contribute one or more
+ /// OpenAPI security scheme definitions.
+ ///
+ /// The configured instance.
+ ///
+ /// The method registers API explorer support and creates Swagger document named v1.
+ ///
+ /// A built-in HTTP Bearer authentication scheme is registered for JWT
+ /// authentication. Every security scheme exposed by a loaded plugin is also
+ /// added to the generated OpenAPI document together with a corresponding
+ /// security requirement.
+ ///
+ ///
+ public static IServiceCollection AddRestfulServices(this IServiceCollection services, IReadOnlyList plugins)
+ {
+ services.AddEndpointsApiExplorer();
+
+ services.AddSwaggerGen(c =>
+ {
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" });
+ c.EnableAnnotations();
+
+ c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
+ {
+ Name = "Authorization",
+ Type = SecuritySchemeType.Http,
+ Scheme = "Bearer",
+ BearerFormat = "JWT",
+ In = ParameterLocation.Header,
+ Description = "Insert JWT token in the format: Bearer {token}"
+ });
+
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
+ },
+ Array.Empty()
+ }
+ });
+
+ foreach (var lp in plugins)
+ {
+ foreach (var (name, descriptor) in lp.Plugin.GetSecuritySchemes())
+ {
+ c.AddSecurityDefinition(name, ToOpenApiSecurityScheme(descriptor));
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = name }
+ },
+ Array.Empty()
+ }
+ });
+ }
+ }
+ });
+
+ return services;
+ }
+
+ private static OpenApiSecurityScheme ToOpenApiSecurityScheme(AuthKitSecuritySchemeDescriptor descriptor) =>
+ new()
+ {
+ 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
+ };
+}
diff --git a/Host/Services/GreeterService.cs b/src/Host/Grpc/GreeterService.cs
similarity index 94%
rename from Host/Services/GreeterService.cs
rename to src/Host/Grpc/GreeterService.cs
index 5f949a6..cf65dee 100644
--- a/Host/Services/GreeterService.cs
+++ b/src/Host/Grpc/GreeterService.cs
@@ -1,6 +1,6 @@
using Grpc.Core;
-namespace Host.Services;
+namespace Host.Grpc;
public class GreeterService(ILogger logger) : Greeter.GreeterBase
{
diff --git a/Infrastructure/Grpc/protos/greet.proto b/src/Host/Grpc/protos/greet.proto
similarity index 100%
rename from Infrastructure/Grpc/protos/greet.proto
rename to src/Host/Grpc/protos/greet.proto
diff --git a/src/Host/Host.csproj b/src/Host/Host.csproj
new file mode 100644
index 0000000..a098d35
--- /dev/null
+++ b/src/Host/Host.csproj
@@ -0,0 +1,33 @@
+
+
+ net10.0
+ 14.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Host/KeyManagement/Repositories/KeyStoreRepository.cs b/src/Host/KeyManagement/Repositories/KeyStoreRepository.cs
new file mode 100644
index 0000000..99c5312
--- /dev/null
+++ b/src/Host/KeyManagement/Repositories/KeyStoreRepository.cs
@@ -0,0 +1,112 @@
+using Core.KeyManagement.Interfaces;
+using Marten;
+
+namespace Host.KeyManagement.Repositories;
+
+///
+/// Provides Marten based repository for persisting the encrypted AuthKit keystore.
+///
+///
+///
+/// The repository stores the keystore as a single document identified by a fixed
+/// document identifier. Only the encrypted representation of the keystore is
+/// persisted; encryption and decryption are handled by the key management layer.
+///
+///
+/// The repository uses lightweight Marten sessions for both read and write
+/// operations and persists changes asynchronously.
+///
+///
+public sealed class KeyStoreRepository(IDocumentStore store) : IKeyStoreRepository
+{
+ ///
+ /// The fixed Marten document identifier used for the singleton keystore document.
+ ///
+ private const string DocumentId = "singleton";
+
+ ///
+ /// Asynchronously loads the encrypted keystore from Marten.
+ ///
+ ///
+ /// A containing the encrypted keystore data,
+ /// or when the keystore does not exist
+ /// or contains no encrypted data.
+ ///
+ public async Task> LoadAsync()
+ {
+ await using var session = store.LightweightSession();
+ var document = await session.LoadAsync(DocumentId);
+
+ if (document is null || document.EncryptedData.Length == 0)
+ return Memory.Empty;
+
+ return document.EncryptedData;
+ }
+
+ ///
+ /// Asynchronously persists the encrypted keystore to Marten.
+ ///
+ ///
+ /// The encrypted keystore data to persist.
+ ///
+ ///
+ ///
+ /// If the singleton keystore document does not exist, a new document is
+ /// created. Otherwise, the existing document is updated with the supplied
+ /// encrypted data.
+ ///
+ ///
+ /// The supplied data is copied into a new byte array before being stored,
+ /// ensuring that the persisted document does not reference the caller's
+ /// directly.
+ ///
+ ///
+ public async Task SaveAsync(ReadOnlyMemory data)
+ {
+ await using var session = store.LightweightSession();
+ var document = await session.LoadAsync(DocumentId);
+
+ if (document is null)
+ {
+ document = new KeystoreDocument
+ {
+ Id = DocumentId,
+ EncryptedData = data.ToArray()
+ };
+
+ session.Store(document);
+ }
+ else
+ {
+ document.EncryptedData = data.ToArray();
+ }
+
+ await session.SaveChangesAsync();
+ }
+
+ ///
+ /// Represents the persisted Marten document containing the encrypted keystore.
+ ///
+ ///
+ ///
+ /// The document intentionally contains only the encrypted keystore payload.
+ /// The repository does not perform encryption or decryption itself.
+ ///
+ ///
+ /// The document uses as its Marten identity and
+ /// as the encrypted key material.
+ ///
+ ///
+ public sealed class KeystoreDocument
+ {
+ ///
+ /// Gets or sets the Marten document identifier.
+ ///
+ public string Id { get; set; } = null!;
+
+ ///
+ /// Gets or sets the encrypted keystore payload.
+ ///
+ public byte[] EncryptedData { get; set; } = null!;
+ }
+}
\ No newline at end of file
diff --git a/Infrastructure/Restful/Jwks/DTO/Response/HealthResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/HealthResponse.cs
similarity index 94%
rename from Infrastructure/Restful/Jwks/DTO/Response/HealthResponse.cs
rename to src/Host/KeyManagement/Restful/DTO/Response/HealthResponse.cs
index 8fe4dcb..94353e3 100644
--- a/Infrastructure/Restful/Jwks/DTO/Response/HealthResponse.cs
+++ b/src/Host/KeyManagement/Restful/DTO/Response/HealthResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace Infrastructure.Restful.Jwks.DTO.Response;
+namespace Host.KeyManagement.Restful.DTO.Response;
///
/// Represents the current health status of the key management subsystem.
diff --git a/Infrastructure/Restful/Jwks/DTO/Response/JwksResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/JwksResponse.cs
similarity index 57%
rename from Infrastructure/Restful/Jwks/DTO/Response/JwksResponse.cs
rename to src/Host/KeyManagement/Restful/DTO/Response/JwksResponse.cs
index 9533dce..5934c10 100644
--- a/Infrastructure/Restful/Jwks/DTO/Response/JwksResponse.cs
+++ b/src/Host/KeyManagement/Restful/DTO/Response/JwksResponse.cs
@@ -1,15 +1,15 @@
using System.Text.Json.Serialization;
-using Application.Features.KeyManagement.DTO;
+using Core.KeyManagement.DTO;
-namespace Infrastructure.Restful.Jwks.DTO.Response;
+namespace Host.KeyManagement.Restful.DTO.Response;
///
-/// Represents a JSON Web Key Set (JWKS) response containing all active public keys.
+/// Represents JSON Web Key Set (JWKS) response containing all active public keys.
///
///
///
/// - Compliant with RFC 7517 (JSON Web Key Set).
-/// - Contains only non-revoked, public RSA keys used for signature verification.
+/// - Contains only non revoked, public RSA keys used for signature verification.
///
///
public record JwksResponse(
diff --git a/Infrastructure/Restful/Jwks/DTO/Response/KeyStoreStatsResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/KeyStoreStatsResponse.cs
similarity index 93%
rename from Infrastructure/Restful/Jwks/DTO/Response/KeyStoreStatsResponse.cs
rename to src/Host/KeyManagement/Restful/DTO/Response/KeyStoreStatsResponse.cs
index ffed0b9..c38a0d3 100644
--- a/Infrastructure/Restful/Jwks/DTO/Response/KeyStoreStatsResponse.cs
+++ b/src/Host/KeyManagement/Restful/DTO/Response/KeyStoreStatsResponse.cs
@@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
-namespace Infrastructure.Restful.Jwks.DTO.Response;
+namespace Host.KeyManagement.Restful.DTO.Response;
///
/// Represents statistical information about the key store state.
diff --git a/Infrastructure/Restful/Jwks/DTO/Response/SingleKeyResponse.cs b/src/Host/KeyManagement/Restful/DTO/Response/SingleKeyResponse.cs
similarity index 73%
rename from Infrastructure/Restful/Jwks/DTO/Response/SingleKeyResponse.cs
rename to src/Host/KeyManagement/Restful/DTO/Response/SingleKeyResponse.cs
index ae2a9a4..df765d0 100644
--- a/Infrastructure/Restful/Jwks/DTO/Response/SingleKeyResponse.cs
+++ b/src/Host/KeyManagement/Restful/DTO/Response/SingleKeyResponse.cs
@@ -1,10 +1,10 @@
using System.Text.Json.Serialization;
-using Application.Features.KeyManagement.DTO;
+using Core.KeyManagement.DTO;
-namespace Infrastructure.Restful.Jwks.DTO.Response;
+namespace Host.KeyManagement.Restful.DTO.Response;
///
-/// Represents a single JSON Web Key (JWK) with its associated metadata.
+/// Represents single JSON Web Key (JWK) with its associated metadata.
///
///
///
diff --git a/Infrastructure/Restful/Jwks/JwksController.cs b/src/Host/KeyManagement/Restful/JwksController.cs
similarity index 85%
rename from Infrastructure/Restful/Jwks/JwksController.cs
rename to src/Host/KeyManagement/Restful/JwksController.cs
index f76f39b..6d6e297 100644
--- a/Infrastructure/Restful/Jwks/JwksController.cs
+++ b/src/Host/KeyManagement/Restful/JwksController.cs
@@ -1,13 +1,11 @@
-using Application;
-using Application.Features.KeyManagement.DTO;
-using Application.Features.KeyManagement.Interfaces;
-using Infrastructure.Restful.Jwks.DTO.Response;
+using Core;
+using Core.KeyManagement.DTO;
+using Core.KeyManagement.Interfaces;
+using Host.KeyManagement.Restful.DTO.Response;
using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Logging;
-namespace Infrastructure.Restful.Jwks;
+namespace Host.KeyManagement.Restful;
///
/// Exposes the JSON Web Key Set (JWKS) endpoint for public key discovery.
@@ -26,9 +24,31 @@ public class JwksController(IJwtKeyStore keyStore, ILogger logge
#region Public JWKS Endpoints
///
- /// Returns all currently active public keys in JWKS format.
+ /// Returns all currently available public signing keys in JWKS format.
///
- /// A containing all active public keys.
+ ///
+ /// A containing the public JWK representations
+ /// of all non revoked signing keys.
+ ///
+ ///
+ ///
+ /// This endpoint is intended for JWT consumers that need to discover
+ /// the public keys required to validate token signatures.
+ ///
+ ///
+ /// Multiple keys may be returned simultaneously to support signing key
+ /// rotation while tokens signed with previously active keys remain
+ /// verifiable.
+ ///
+ ///
+ /// The response is publicly accessible and cached for one hour.
+ /// The X-Key-Count response header contains the number of
+ /// published keys and X-Generated-At contains the response
+ /// generation timestamp.
+ ///
+ ///
+ /// The JWKS document was successfully generated.
+ /// The public key set could not be retrieved.
[AllowAnonymous]
[HttpGet]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByHeader = "Accept")]
@@ -93,7 +113,7 @@ public IActionResult GetKeyByKid([FromRoute] string kid)
Alg: k.Alg,
N: k.N,
E: k.E,
- X5c: k.X5c?.ToArray() ?? []
+ X5c: k.X5c.ToArray()
))
.FirstOrDefault();
diff --git a/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs
new file mode 100644
index 0000000..a066f4e
--- /dev/null
+++ b/src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs
@@ -0,0 +1,94 @@
+using System.Diagnostics;
+using Core.KeyManagement.Interfaces;
+using Spectre.Console;
+
+namespace Host.KeyManagement.Security;
+
+///
+/// Initializes and disposes the JWT key store as part of the application host lifecycle.
+///
+///
+///
+/// The initializer resolves from a scoped service
+/// provider and initializes its persisted key material before the application
+/// begins serving requests.
+///
+///
+/// Initialization progress is displayed using a Spectre.Console status spinner,
+/// while the resulting initialization duration, active key identifier, and
+/// number of available public keys are written to the application logger.
+///
+///
+/// During application shutdown, the hosted service disposes the key store when
+/// it implements , allowing cryptographic resources
+/// such as RSA instances to be released safely.
+///
+///
+public sealed class JwtKeyStoreInitializer(
+ IServiceProvider provider,
+ ILogger logger)
+ : IHostedService, IAsyncDisposable
+{
+ ///
+ /// Initializes the JWT key store during application startup.
+ ///
+ /// Token that can be used to signal cancellation of the startup operation.
+ ///
+ ///
+ /// A temporary asynchronous service scope is created to resolve the
+ /// instance.
+ ///
+ ///
+ /// The initialization duration is measured using
+ /// and reported after the key store has been
+ /// successfully initialized.
+ ///
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ await using var scope = provider.CreateAsyncScope();
+
+ var store =
+ scope.ServiceProvider.GetRequiredService();
+
+ var stopwatch = Stopwatch.StartNew();
+
+ await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .SpinnerStyle(Style.Parse("green"))
+ .StartAsync(
+ "Initializing JWT KeyStore...",
+ async _ => await store.InitializeAsync());
+
+ stopwatch.Stop();
+
+ var activeCredentials =
+ store.GetActiveSigningCredentials();
+
+ var activeKid =
+ store.GetMetadata(activeCredentials.Key.KeyId)?.Kid ?? "N/A";
+
+ var totalKeys =
+ store.GetPublicJwks().Count();
+
+ logger.LogInformation(
+ "JWT Keystore initialized in {ElapsedMilliseconds} ms | Active KID: {ActiveKid} | Total Keys: {TotalKeys}",
+ stopwatch.ElapsedMilliseconds,
+ activeKid,
+ totalKeys);
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public async ValueTask DisposeAsync()
+ {
+ await using var scope = provider.CreateAsyncScope();
+
+ if (scope.ServiceProvider.GetService()
+ is IAsyncDisposable asyncStore)
+ {
+ await asyncStore.DisposeAsync();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Host/Plugins/LoadedPlugin.cs b/src/Host/Plugins/LoadedPlugin.cs
new file mode 100644
index 0000000..2f69cd3
--- /dev/null
+++ b/src/Host/Plugins/LoadedPlugin.cs
@@ -0,0 +1,26 @@
+using System.Reflection;
+using AuthKit.Plugins.Abstractions;
+
+namespace Host.Plugins;
+
+///
+/// Represents plugin loaded from disk together with its contract instance,
+/// assembly, and source directory.
+///
+///
+///
+/// The host uses the loaded assembly to register plugin provided
+/// Wolverine handlers and MVC application parts.
+///
+///
+/// The plugin directory is retained for diagnostics and identifying the
+/// location from which the plugin was loaded.
+///
+///
+/// The loaded AuthKit plugin contract instance.
+/// The assembly containing the loaded plugin.
+/// The directory from which the plugin was loaded.
+public sealed record LoadedPlugin(
+ IAuthKitPlugin Plugin,
+ Assembly Assembly,
+ string PluginDirectory);
\ No newline at end of file
diff --git a/src/Host/Plugins/PluginLoader.cs b/src/Host/Plugins/PluginLoader.cs
new file mode 100644
index 0000000..452be07
--- /dev/null
+++ b/src/Host/Plugins/PluginLoader.cs
@@ -0,0 +1,99 @@
+using System.Runtime.Loader;
+using AuthKit.Plugins.Abstractions;
+
+namespace Host.Plugins;
+
+///
+/// Discovers and loads AuthKit plugins from specified directory during host startup.
+///
+///
+///
+/// Plugins are loaded before
+/// is called, allowing infrastructure such as Wolverine, Marten, and MVC to discover
+/// plugin assemblies while their configuration is being built.
+///
+///
+/// Plugins are loaded into rather than an
+/// isolated load context. This ensures that shared framework and package types, such
+/// as Wolverine IMessageBus, Marten IDocumentSession, and ASP.NET Core
+/// MVC types, resolve to the same runtime types on both sides of the plugin boundary.
+///
+///
+/// The loader does not support hot unloading or hot swapping of plugins. Plugins are
+/// expected to remain loaded for the lifetime of the host process.
+///
+///
+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.
+ /// 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.
+ ///
+ public static IReadOnlyList LoadPlugins(string pluginsRootPath, ILogger logger)
+ {
+ if (!Directory.Exists(pluginsRootPath))
+ {
+ logger.LogWarning("Plugins path '{Path}' does not exist — starting with zero plugins.", pluginsRootPath);
+ return [];
+ }
+
+ var loaded = new List();
+
+ foreach (var pluginDir in Directory.GetDirectories(pluginsRootPath))
+ {
+ var pluginName = Path.GetFileName(pluginDir);
+ var entryDllPath = Path.Combine(pluginDir, $"{pluginName}.dll");
+
+ if (!File.Exists(entryDllPath))
+ {
+ logger.LogError(
+ "Skipping plugin folder '{Dir}': expected entry assembly '{Dll}' not found.",
+ pluginDir, entryDllPath);
+ continue;
+ }
+
+ 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;
+ };
+
+ var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(entryDllPath);
+
+ var pluginType = assembly.GetTypes()
+ .FirstOrDefault(t => t is { IsPublic: true, IsAbstract: false }
+ && typeof(IAuthKitPlugin).IsAssignableFrom(t)
+ && t.GetConstructor(Type.EmptyTypes) is not null);
+
+ if (pluginType is null)
+ {
+ logger.LogError(
+ "Skipping plugin assembly '{Dll}': no public, non-abstract IAuthKitPlugin implementation with a parameterless constructor found.",
+ entryDllPath);
+ continue;
+ }
+
+ var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!;
+ loaded.Add(new LoadedPlugin(plugin, assembly, pluginDir));
+
+ logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Name, plugin.Version, pluginDir);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir);
+ }
+ }
+
+ return loaded;
+ }
+}
diff --git a/src/Host/Program.cs b/src/Host/Program.cs
new file mode 100644
index 0000000..7446931
--- /dev/null
+++ b/src/Host/Program.cs
@@ -0,0 +1,44 @@
+using Host.Configuration;
+using Host.Plugins;
+using Host.Cli;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// === Plugin discovery (before DI/Wolverine/Marten are configured) ===
+var pluginsPath = builder.Configuration["AuthKit:PluginsPath"]
+ ?? Path.Combine(AppContext.BaseDirectory, "plugins");
+
+var pluginLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("PluginLoader");
+var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger);
+
+// === Core Config ===
+builder.Services.AddSingleton(plugins);
+builder.Services.AddAuthKitCore();
+
+builder.Services.ConfigureApp(builder.Configuration, plugins)
+ .AddGrpcServices()
+ .AddRestfulServices(plugins)
+ .AddKeycloakServices();
+
+foreach (var lp in plugins)
+ lp.Plugin.ConfigureServices(builder.Services, builder.Configuration);
+
+builder.ConfigureWolverine(plugins);
+builder.Services.ConfigureMarten(builder.Configuration);
+builder.WebHost.ConfigureKestrelServer();
+
+builder.Services.Configure(
+ builder.Configuration.GetSection("Server")
+);
+
+builder.Services.AddHostedService();
+
+
+// === Pipeline ===
+var app = builder.Build();
+
+app.ConfigureMiddleware(plugins)
+ .MapAppEndpoints()
+ .MapGrpcEndpoints();
+
+app.Run();
diff --git a/Host/Properties/launchSettings.json b/src/Host/Properties/launchSettings.json
similarity index 100%
rename from Host/Properties/launchSettings.json
rename to src/Host/Properties/launchSettings.json
diff --git a/src/Host/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler.cs b/src/Host/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler.cs
new file mode 100644
index 0000000..8a2fcf5
--- /dev/null
+++ b/src/Host/Restful/Middleware/Exceptions/CustomAuthorizationMiddlewareResultHandler.cs
@@ -0,0 +1,122 @@
+using System.Net;
+using System.Text.Json;
+using Core.Options;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization.Policy;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Host.Restful.Middleware.Exceptions;
+
+///
+/// Handles authorization results and returns standardized RFC 7807
+/// responses for unauthorized and forbidden requests.
+///
+///
+///
+/// Converts authorization challenges and forbidden results into consistent
+/// JSON responses.
+///
+///
+/// Uses to generate documentation URLs
+/// for authorization errors.
+///
+/// Logs security related authorization failures for auditing and diagnostics.
+///
+public sealed class CustomAuthorizationMiddlewareResultHandler(
+ IOptions options) : IAuthorizationMiddlewareResultHandler
+{
+ private readonly AuthorizationMiddlewareResultHandler _defaultHandler = new();
+ private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/');
+
+ ///
+ /// Handles the result of an authorization policy evaluation.
+ ///
+ /// The next middleware in the request pipeline.
+ /// The current HTTP request context.
+ /// The authorization policy that was evaluated.
+ /// The result of the authorization policy evaluation.
+ /// A task representing the asynchronous operation.
+ public async Task HandleAsync(
+ RequestDelegate next,
+ HttpContext context,
+ AuthorizationPolicy policy,
+ PolicyAuthorizationResult authorizeResult)
+ {
+ if (authorizeResult.Challenged)
+ {
+ await WriteProblemAsync(
+ context,
+ status: HttpStatusCode.Unauthorized,
+ code: "unauthorized",
+ title: "Unauthorized",
+ detail: "Authentication required to access this resource.");
+
+ return;
+ }
+
+ if (authorizeResult.Forbidden)
+ {
+ await WriteProblemAsync(
+ context,
+ status: HttpStatusCode.Forbidden,
+ code: "forbidden",
+ title: "Forbidden",
+ detail: "You do not have permission to access this resource.");
+
+ return;
+ }
+
+ await _defaultHandler.HandleAsync(
+ next,
+ context,
+ policy,
+ authorizeResult);
+ }
+
+ ///
+ /// Writes standardized RFC 7807 response
+ /// for an authorization failure.
+ ///
+ /// The current HTTP request context.
+ /// The HTTP status code returned to the client.
+ /// The application-specific error code.
+ /// The human-readable error title.
+ /// The human-readable error description.
+ /// A task representing the asynchronous response-writing operation.
+ private async Task WriteProblemAsync(
+ HttpContext context,
+ HttpStatusCode status,
+ string code,
+ string title,
+ string detail)
+ {
+ var logger = context.RequestServices
+ .GetRequiredService<
+ ILogger>();
+
+ logger.LogWarning("{Code} access attempt at {Path}",
+ code,
+ context.Request.Path);
+
+ var problem = new ProblemDetails
+ {
+ Type = $"{_baseUrl}/{code}",
+ Title = title,
+ Detail = detail,
+ Status = (int)status,
+ Instance = context.TraceIdentifier,
+ Extensions =
+ {
+ ["error_code"] = code,
+ ["trace_id"] = context.TraceIdentifier
+ }
+ };
+
+ context.Response.StatusCode = problem.Status!.Value;
+ context.Response.ContentType = "application/problem+json";
+
+ var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ await context.Response.WriteAsync(json);
+ }
+}
diff --git a/src/Host/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs b/src/Host/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs
new file mode 100644
index 0000000..28ea0d1
--- /dev/null
+++ b/src/Host/Restful/Middleware/Exceptions/ExceptionHandlingMiddleware.cs
@@ -0,0 +1,144 @@
+using System.Net;
+using System.Text.Json;
+using Core;
+using Core.Options;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Host.Restful.Middleware.Exceptions;
+
+///
+/// Middleware that handles unhandled exceptions and returns standardized
+/// RFC 7807 responses.
+///
+///
+///
+/// Domain specific exceptions represented by
+/// are returned as 409 Conflict responses with an error code derived
+/// from the exception type name.
+///
+///
+/// Unexpected exceptions are returned as 500 Internal Server Error
+/// responses without exposing internal exception details to the client.
+///
+///
+/// Problem type URLs are generated using
+/// and the corresponding error code.
+///
+///
+public class ExceptionHandlingMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ IOptions options)
+{
+ private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/');
+
+ ///
+ /// Invokes the next middleware and handles any unhandled exception
+ /// produced during request processing.
+ ///
+ /// The current HTTP request context.
+ /// A task representing the asynchronous middleware operation.
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await next(context);
+ }
+ catch (Exception ex)
+ {
+ await HandleAsync(context, ex);
+ }
+ }
+
+ ///
+ /// Maps an exception to standardized
+ /// response and writes it to the HTTP response.
+ ///
+ /// The current HTTP request context.
+ /// The exception that was raised during request processing.
+ /// A task representing the asynchronous response-writing operation.
+ private async Task HandleAsync(HttpContext context, Exception ex)
+ {
+ var (status, code, message, isDomainError) = MapException(ex);
+
+ context.Response.ContentType = "application/problem+json";
+ context.Response.StatusCode = (int)status;
+
+ var problem = new ProblemDetails
+ {
+ Type = $"{_baseUrl}/{code}",
+ Title = code.Replace('_', ' '),
+ Detail = message,
+ Status = (int)status,
+ Instance = context.TraceIdentifier,
+ Extensions =
+ {
+ ["error_code"] = code,
+ ["trace_id"] = context.TraceIdentifier
+ }
+ };
+
+ if (isDomainError)
+ {
+ logger.LogWarning(
+ "Handled domain exception {ErrorCode}: {Message}",
+ code,
+ message);
+ }
+ else
+ {
+ logger.LogError(
+ ex,
+ "Unhandled exception: {Message}",
+ message);
+ }
+
+ var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+
+ await context.Response.WriteAsync(json);
+ }
+
+ ///
+ /// Maps an exception to its corresponding HTTP status code, error code,
+ /// client-safe message, and domain-error indicator.
+ ///
+ /// The exception to map.
+ ///
+ /// A tuple containing the HTTP status, error code, response message,
+ /// and a value indicating whether the exception is a domain error.
+ ///
+ private static (
+ HttpStatusCode Status,
+ string Code,
+ string Message,
+ bool IsDomainError) MapException(Exception ex)
+ {
+ if (ex is not DomainException domainEx)
+ {
+ return(
+ HttpStatusCode.InternalServerError,
+ "internal_error",
+ "An unexpected error occurred.",
+ false);
+ }
+
+ var code = ToSnakeCase(domainEx.GetType().Name.Replace("Exception", ""));
+ return(
+ HttpStatusCode.Conflict,
+ code,
+ domainEx.Message,
+ true);
+ }
+
+ ///
+ /// Converts PascalCase or camelCase string to snake_case.
+ ///
+ /// The string to convert.
+ /// The converted snake_case string.
+ private static string ToSnakeCase(string input) =>
+ string.Concat(input.Select((ch, i) =>
+ i > 0 && char.IsUpper(ch)
+ ? "_" + char.ToLower(ch)
+ : char.ToLower(ch).ToString()));
+}
diff --git a/src/Host/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs b/src/Host/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs
new file mode 100644
index 0000000..2e73368
--- /dev/null
+++ b/src/Host/Restful/Middleware/Exceptions/ValidationExceptionMiddleware.cs
@@ -0,0 +1,101 @@
+using System.Net;
+using System.Text.Json;
+using Core.Options;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Host.Restful.Middleware.Exceptions;
+
+///
+/// Middleware that handles instances and returns
+/// standardized RFC 7807 responses.
+///
+///
+///
+/// Validation failures are returned as 400 Bad Request responses containing
+/// structured validation errors grouped by property name.
+///
+///
+/// Uses to generate the problem
+/// documentation URL.
+///
+///
+/// Validation failures are logged together with the request path and validation
+/// error details for diagnostics.
+///
+///
+public sealed class ValidationExceptionMiddleware(
+ RequestDelegate next,
+ ILogger logger,
+ IOptions options)
+{
+ private readonly string _baseUrl = options.Value.DocsBaseUrl.TrimEnd('/');
+
+ ///
+ /// Invokes the next middleware and handles any
+ /// raised during request processing.
+ ///
+ /// The current HTTP request context.
+ /// A task representing the asynchronous middleware operation.
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await next(context);
+ }
+ catch (ValidationException ex)
+ {
+ await HandleValidationAsync(context, ex);
+ }
+ }
+
+ ///
+ /// Creates and writes standardized response
+ /// containing the validation errors.
+ ///
+ /// The current HTTP request context.
+ /// The validation exception containing the validation failures.
+ /// A task representing the asynchronous response-writing operation.
+ private async Task HandleValidationAsync(
+ HttpContext context,
+ ValidationException ex)
+ {
+ const string code = "validation_failed";
+ const HttpStatusCode status = HttpStatusCode.BadRequest;
+
+ var errors = ex.Errors
+ .GroupBy(e => e.PropertyName)
+ .ToDictionary(
+ group => group.Key,
+ group => group
+ .Select(e => e.ErrorMessage)
+ .ToArray());
+
+ var problem = new ProblemDetails
+ {
+ Type = $"{_baseUrl}/{code}",
+ Title = "Validation failed",
+ Detail = "One or more validation errors occurred.",
+ Status = (int)status,
+ Instance = context.TraceIdentifier,
+ Extensions =
+ {
+ ["error_code"] = code,
+ ["trace_id"] = context.TraceIdentifier,
+ ["errors"] = errors
+ }
+ };
+
+ logger.LogWarning(
+ "Validation failed on {Path}: {@Errors}",
+ context.Request.Path,
+ errors);
+
+ context.Response.StatusCode = problem.Status!.Value;
+ context.Response.ContentType = "application/problem+json";
+
+ var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ await context.Response.WriteAsync(json);
+ }
+}
diff --git a/src/Host/ServiceDiscovery/ServiceDiscoveryExtensions.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryExtensions.cs
new file mode 100644
index 0000000..734067d
--- /dev/null
+++ b/src/Host/ServiceDiscovery/ServiceDiscoveryExtensions.cs
@@ -0,0 +1,67 @@
+using System.Reflection;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Host.ServiceDiscovery;
+
+///
+/// Provides extension methods for automatically discovering and registering
+/// application services in the dependency injection container.
+///
+///
+///
+/// Services are discovered by scanning the specified assemblies and filtering
+/// types according to .
+///
+///
+/// Discovered concrete service types are registered against their implemented
+/// interfaces using the configured service lifetime.
+///
+///
+public static class ServiceDiscoveryExtensions
+{
+ ///
+ /// Scans the specified assemblies for service types and registers the
+ /// discovered services in the dependency injection container.
+ ///
+ /// The to register discovered services into.
+ /// The assemblies to scan for service implementations.
+ /// An optional callback used to configure .
+ /// An optional logger used to report service discovery and filtering information.
+ /// The original instance for method chaining.
+ ///
+ ///
+ /// Types are filtered using
+ /// and the configured .
+ /// Interfaces and exception types can be excluded according to the configured
+ /// discovery options.
+ ///
+ ///
+ /// Valid concrete service types are registered as their implemented interfaces
+ /// using the configured .
+ ///
+ ///
+ public static IServiceCollection AddDiscoveredServices(
+ this IServiceCollection services,
+ Assembly[] assemblies,
+ Action? configure = null,
+ ILogger? logger = null)
+ {
+ var opts = new ServiceDiscoveryOptions();
+ configure?.Invoke(opts);
+
+ logger ??= NullLogger.Instance;
+
+ services.Scan(scan => scan
+ .FromAssemblies(assemblies)
+ .AddClasses(classes => classes
+ .Where(type =>
+ ServiceDiscoveryFilter.IsValidServiceType(
+ type,
+ logger,
+ opts)))
+ .AsImplementedInterfaces()
+ .WithLifetime(opts.Lifetime));
+
+ return services;
+ }
+}
diff --git a/Host/ServiceDiscoveryFilter.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs
similarity index 76%
rename from Host/ServiceDiscoveryFilter.cs
rename to src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs
index c8a339a..83e8032 100644
--- a/Host/ServiceDiscoveryFilter.cs
+++ b/src/Host/ServiceDiscovery/ServiceDiscoveryFilter.cs
@@ -1,4 +1,4 @@
-namespace Host;
+namespace Host.ServiceDiscovery;
///
/// Filters types during service discovery for automatic DI registration.
@@ -6,10 +6,10 @@ namespace Host;
///
///
/// - Detects type category based on namespace markers (DTO, Entity, Repository, Services, etc.).
-/// - Extracts a feature name from ".Features.{FeatureName}".
+/// - Extracts feature name from the namespace segment right after the layer root.
/// - Extracts layer from the namespace root (Application, Domain, Infrastructure, etc.).
/// - Applies including allowed/excluded namespaces, layers, interfaces, exceptions, and types.
-/// - Logs a clear debug message indicating decision, layer, type category, feature, and full type name.
+/// - Logs clear debug message indicating decision, layer, type category, feature, and full type name.
///
///
public static class ServiceDiscoveryFilter
@@ -28,7 +28,7 @@ private static readonly (string Key, string Name)[] NamespaceTypes =
];
///
- /// Determines if a type is valid for automatic DI registration.
+ /// Determines if type is valid for automatic DI registration.
///
/// Type to evaluate.
/// Logger for debug output.
@@ -45,7 +45,8 @@ public static bool IsValidServiceType(Type type, ILogger logger, ServiceDiscover
if (opts.EnableLogging)
{
- logger.LogDebug("{Decision} Layer({Layer}) Type({TypeKind}) Feature({Feature}): {Type}",
+ logger.LogDebug(
+ "{Decision} Layer({Layer}) Type({TypeKind}) Feature({Feature}): {Type}",
include ? "Including" : "Skipping",
layer,
detectedType,
@@ -56,42 +57,37 @@ public static bool IsValidServiceType(Type type, ILogger logger, ServiceDiscover
return include;
}
-
-
-
-
///
/// Returns the first namespace segment (layer), e.g. "Application", "Domain".
///
private static string GetLayer(string ns)
- => ns.Split('.', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? "Unknown";
+ => ns.Split('.', StringSplitOptions.RemoveEmptyEntries)
+ .FirstOrDefault() ?? "Unknown";
///
- /// Detects a type group (DTO, Entity, Service, etc.) based on namespace markers.
+ /// Detects type group (DTO, Entity, Service, etc.) based on namespace markers.
///
private static string GetDetectedType(string ns)
{
foreach (var (key, name) in NamespaceTypes)
+ {
if (ns.Contains(key))
return name;
+ }
return "Allowed";
}
///
- /// Extracts feature (the part after ".Features.") or returns "Global".
+ /// Extracts feature (the namespace segment right after the layer root, e.g.
+ /// "KeyManagement" from "Core.KeyManagement.Services") or returns "Global".
///
private static string GetFeature(string ns)
{
- const string segment = ".Features.";
- var idx = ns.IndexOf(segment, StringComparison.Ordinal);
- if (idx < 0) return "Global";
-
- var start = idx + segment.Length;
- var end = ns.IndexOf('.', start);
- return end > 0 ? ns[start..end] : ns[start..];
+ var parts = ns.Split('.', StringSplitOptions.RemoveEmptyEntries);
+ return parts.Length > 1 ? parts[1] : "Global";
}
-
+
///
/// Applies rules to decide inclusion.
///
@@ -102,4 +98,4 @@ private static bool ShouldInclude(Type type, string ns, string layer, ServiceDis
&& !opts.ExcludedNamespaces.Any(ns.Contains)
&& (opts.AllowedNamespaces.Count == 0 || opts.AllowedNamespaces.Any(ns.Contains))
&& (opts.AllowedLayers.Count == 0 || opts.AllowedLayers.Contains(layer));
-}
\ No newline at end of file
+}
diff --git a/Host/ServiceDiscoveryOptions.cs b/src/Host/ServiceDiscovery/ServiceDiscoveryOptions.cs
similarity index 97%
rename from Host/ServiceDiscoveryOptions.cs
rename to src/Host/ServiceDiscovery/ServiceDiscoveryOptions.cs
index d1749cc..d42881e 100644
--- a/Host/ServiceDiscoveryOptions.cs
+++ b/src/Host/ServiceDiscovery/ServiceDiscoveryOptions.cs
@@ -1,4 +1,4 @@
-namespace Host;
+namespace Host.ServiceDiscovery;
///
/// Configuration options for automatic service discovery and DI registration.
@@ -26,7 +26,7 @@ public sealed class ServiceDiscoveryOptions
/// Specific types to ignore during service registration.
///
public List ExcludedTypes { get; set; } = [];
-
+
public List AllowedLayers { get; set; } = [];
///
@@ -43,9 +43,9 @@ public sealed class ServiceDiscoveryOptions
/// If false, ServiceDiscovery will not log debug messages.
///
public bool EnableLogging { get; set; } = true;
-
+
///
/// Default service lifetime for all discovered types.
///
public ServiceLifetime Lifetime { get; set; } = ServiceLifetime.Scoped;
-}
\ No newline at end of file
+}
diff --git a/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs b/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs
new file mode 100644
index 0000000..c174bea
--- /dev/null
+++ b/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs
@@ -0,0 +1,141 @@
+using System.Diagnostics;
+using Core.TokenKeyBindings;
+using Core.TokenKeyBindings.Interfaces;
+using Core.TokenKeyBindings.Services;
+
+namespace Host.TokenKeyBindings.Repositories;
+
+///
+/// Provides an in-memory implementation of
+/// for storing entities.
+///
+///
+///
+/// Stores key bindings in process memory and is primarily intended for testing,
+/// local development, and scenarios that do not require persistent storage.
+///
+///
+/// All repository operations are synchronized using an internal lock to ensure
+/// thread-safe access to the in-memory collection.
+///
+///
+/// Repository operations are written to the debug output for diagnostics and
+/// development purposes.
+///
+///
+public class InMemoryKeyBindingRepository : IKeyBindingRepository
+{
+ private readonly List _store = [];
+ private readonly Lock _lock = new();
+
+ ///
+ /// Writes repository diagnostic message to the debug output.
+ ///
+ /// The diagnostic message to write.
+ private static void DebugLog(string message)
+ => Debug.WriteLine($"[KeyBindingRepo] {message}");
+
+ ///
+ /// Adds new key binding to the in-memory store.
+ ///
+ /// The key binding to store.
+ ///
+ /// A task containing the stored .
+ ///
+ public Task AddAsync(TokenKeyBinding binding)
+ {
+ lock (_lock)
+ {
+ _store.Add(binding);
+ }
+
+ DebugLog(
+ $"Added binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}");
+
+ return Task.FromResult(binding);
+ }
+
+ ///
+ /// Retrieves key binding by developer token ID and signing key ID.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key.
+ ///
+ /// A task containing the matching ,
+ /// or null when no matching binding exists.
+ ///
+ public Task GetAsync(
+ Guid tokenId,
+ string signingKeyId)
+ {
+ TokenKeyBinding? found;
+
+ lock (_lock)
+ {
+ found = _store.FirstOrDefault(
+ binding =>
+ binding.TokenId == tokenId &&
+ binding.SigningKeyId == signingKeyId);
+ }
+
+ DebugLog(
+ found != null
+ ? $"Found binding for TokenId={tokenId}, SigningKeyId={signingKeyId}"
+ : $"No binding found for TokenId={tokenId}, SigningKeyId={signingKeyId}");
+
+ return Task.FromResult(found);
+ }
+
+ ///
+ /// Updates an existing key binding in the in-memory store.
+ ///
+ /// The updated key binding.
+ /// A task representing the asynchronous update operation.
+ public Task UpdateAsync(TokenKeyBinding binding)
+ {
+ lock (_lock)
+ {
+ var index = _store.FindIndex(
+ existing =>
+ existing.TokenId == binding.TokenId &&
+ existing.SigningKeyId == binding.SigningKeyId);
+
+ if (index >= 0)
+ {
+ _store[index] = binding;
+
+ DebugLog(
+ $"Updated binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}");
+ }
+ else
+ {
+ DebugLog(
+ $"Attempted to update non-existing binding: TokenId={binding.TokenId}, SigningKeyId={binding.SigningKeyId}");
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Lists all key bindings associated with a developer token.
+ ///
+ /// The unique identifier of the developer token.
+ ///
+ /// A task containing all entities associated
+ /// with the specified developer token.
+ ///
+ public Task> ListByTokenAsync(Guid tokenId)
+ {
+ IEnumerable result;
+
+ lock (_lock)
+ {
+ result = [.. _store.Where(binding => binding.TokenId == tokenId)];
+ }
+
+ DebugLog($"Listed {result.Count()} bindings for TokenId={tokenId}");
+
+ return Task.FromResult(result);
+ }
+}
diff --git a/Host/appsettings.Development.json b/src/Host/appsettings.Development.json
similarity index 92%
rename from Host/appsettings.Development.json
rename to src/Host/appsettings.Development.json
index f026755..85ebada 100644
--- a/Host/appsettings.Development.json
+++ b/src/Host/appsettings.Development.json
@@ -23,9 +23,7 @@
".ValueObject"
],
"AllowedLayers": [
- "Application",
- "Domain",
- "Infrastructure",
+ "Core",
"Host"
],
"SkipInterfaces": true,
diff --git a/Host/appsettings.json b/src/Host/appsettings.json
similarity index 94%
rename from Host/appsettings.json
rename to src/Host/appsettings.json
index a3b4f21..b7cf050 100644
--- a/Host/appsettings.json
+++ b/src/Host/appsettings.json
@@ -29,9 +29,7 @@
".ValueObject"
],
"AllowedLayers": [
- "Application",
- "Domain",
- "Infrastructure",
+ "Core",
"Host"
],
"SkipInterfaces": true,
diff --git a/Domain/Domain.csproj b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj
similarity index 52%
rename from Domain/Domain.csproj
rename to src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj
index 33b5e3b..d327ce9 100644
--- a/Domain/Domain.csproj
+++ b/src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj
@@ -5,4 +5,8 @@
enable
enable
-
\ No newline at end of file
+
+
+
+
+
diff --git a/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs
new file mode 100644
index 0000000..dcc9c01
--- /dev/null
+++ b/src/Plugins/Abstractions/AuthKitApiKeyLocation.cs
@@ -0,0 +1,65 @@
+namespace AuthKit.Plugins.Abstractions;
+
+///
+/// Specifies the transport location from which an API key should be retrieved.
+///
+///
+///
+/// The API key can be supplied through different metadata locations depending
+/// 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
+/// .
+///
+///
+/// The selected location determines where the AuthKit authentication pipeline
+/// searches for the API key before attempting validation.
+///
+///
+public enum AuthKitApiKeyLocation
+{
+ ///
+ /// Retrieves the API key from request headers or transport metadata.
+ ///
+ ///
+ ///
+ /// For HTTP requests, the API key is retrieved from configured request
+ /// header, such as X-Api-Key.
+ ///
+ ///
+ /// For gRPC requests, the API key is retrieved from the request metadata.
+ ///
+ ///
+ Header,
+
+ ///
+ /// Retrieves the API key from request query parameter.
+ ///
+ ///
+ /// The API key is expected to be supplied as part of the HTTP request URL,
+ /// for example ?api_key=....
+ ///
+ ///
+ /// This location is only applicable to HTTP-based requests and is not
+ /// available for native gRPC calls.
+ ///
+ Query,
+
+ ///
+ /// Retrieves the API key from a request cookie.
+ ///
+ ///
+ ///
+ /// The API key is expected to be stored in an HTTP cookie sent with the
+ /// incoming request.
+ ///
+ ///
+ /// This location is primarily intended for HTTP browser-based scenarios
+ /// and is not available for native gRPC calls.
+ ///
+ ///
+ Cookie
+}
diff --git a/src/Plugins/Abstractions/AuthKitSecuritySchemeDescriptor.cs b/src/Plugins/Abstractions/AuthKitSecuritySchemeDescriptor.cs
new file mode 100644
index 0000000..0167bec
--- /dev/null
+++ b/src/Plugins/Abstractions/AuthKitSecuritySchemeDescriptor.cs
@@ -0,0 +1,62 @@
+namespace AuthKit.Plugins.Abstractions;
+
+///
+/// Describes security scheme exposed by an AuthKit authentication plugin.
+///
+///
+///
+/// provides transport-agnostic
+/// metadata describing how client authenticates when communicating with
+/// service protected by AuthKit.
+///
+///
+/// Depending on , the descriptor can represent authentication
+/// mechanisms such as API keys, HTTP authentication schemes, or bearer tokens.
+///
+///
+/// For API key schemes, determines where AuthKit should look
+/// for the credential. The selected location may apply differently depending
+/// on the underlying transport, such as HTTP or gRPC.
+///
+///
+public sealed record AuthKitSecuritySchemeDescriptor
+{
+ ///
+ /// The unique name used to identify the security scheme.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// The type of authentication mechanism implemented by the security scheme.
+ ///
+ public required AuthKitSecuritySchemeType Type { get; init; }
+
+ ///
+ /// Specifies where the API key should be retrieved from when
+ /// represents an API key authentication scheme.
+ ///
+ public AuthKitApiKeyLocation In { get; init; }
+
+ ///
+ /// The authentication scheme name.
+ ///
+ ///
+ /// For HTTP authentication schemes, this can specify the scheme used
+ /// by the security mechanism, such as Bearer.
+ ///
+ public string? Scheme { get; init; }
+
+ ///
+ /// An optional hint describing the format of a bearer token.
+ ///
+ ///
+ /// This value is intended primarily for documentation and client
+ /// generation purposes and does not affect token validation.
+ ///
+ public string? BearerFormat { get; init; }
+
+ ///
+ /// An optional description of the security scheme.
+ ///
+ public string? Description { get; init; }
+}
\ No newline at end of file
diff --git a/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs
new file mode 100644
index 0000000..ca82ca3
--- /dev/null
+++ b/src/Plugins/Abstractions/AuthKitSecuritySchemeType.cs
@@ -0,0 +1,59 @@
+namespace AuthKit.Plugins.Abstractions;
+
+///
+/// Specifies the authentication mechanism represented by an AuthKit security scheme.
+///
+///
+///
+/// The selected value determines how the corresponding
+/// describes the credentials
+/// and authentication flow exposed by an AuthKit plugin.
+///
+///
+/// This enumeration describes the authentication mechanism at the metadata
+/// level. It does not itself perform authentication, validate credentials,
+/// or establish an authentication session.
+///
+///
+public enum AuthKitSecuritySchemeType
+{
+ ///
+ /// Authentication using an API key supplied through a configured location.
+ ///
+ ///
+ /// The credential location is specified by
+ /// .
+ /// Common locations include an HTTP header, query parameter, or cookie.
+ ///
+ ApiKey,
+
+ ///
+ /// Authentication using an HTTP authentication scheme.
+ ///
+ ///
+ /// The HTTP authentication scheme is specified by
+ /// .
+ /// Examples include Basic, Bearer, and other HTTP
+ /// authentication schemes.
+ ///
+ Http,
+
+ ///
+ /// Authentication using the OAuth 2.0 authorization framework.
+ ///
+ ///
+ /// OAuth 2.0 schemes describe authorization flows in which a client
+ /// obtains an access token from an authorization server and presents
+ /// that token when accessing protected resources.
+ ///
+ OAuth2,
+
+ ///
+ /// 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.
+ ///
+ OpenIdConnect
+}
diff --git a/src/Plugins/Abstractions/IAuthKitPlugin.cs b/src/Plugins/Abstractions/IAuthKitPlugin.cs
new file mode 100644
index 0000000..f20698f
--- /dev/null
+++ b/src/Plugins/Abstractions/IAuthKitPlugin.cs
@@ -0,0 +1,141 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AuthKit.Plugins.Abstractions;
+
+///
+/// Defines the contract implemented by an AuthKit plugin.
+///
+///
+///
+/// AuthKit plugins are discovered and loaded dynamically by the host at startup.
+/// A plugin does not need to be directly referenced by the host project.
+///
+///
+/// The plugin contract allows an extension to contribute services, middleware,
+/// health checks, and OpenAPI security scheme metadata to the host application.
+///
+///
+/// Plugin implementations should keep their integration with the host limited
+/// to the abstractions exposed by this contract and should register any
+/// plugin-specific dependencies through .
+///
+///
+public interface IAuthKitPlugin
+{
+ ///
+ /// Gets the unique name of the plugin.
+ ///
+ ///
+ /// The name is used to identify the plugin in host diagnostics,
+ /// startup output, and other plugin-related metadata.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the version of the plugin.
+ ///
+ ///
+ /// The version is exposed as plugin metadata and may be used by the host
+ /// for diagnostics, compatibility checks, or administrative surfaces.
+ ///
+ string Version { get; }
+
+ ///
+ /// Gets an optional human-readable description of the plugin.
+ ///
+ ///
+ /// The description may be displayed by the host in startup output,
+ /// diagnostics, administrative interfaces, or other status surfaces.
+ ///
+ string? Description => null;
+
+ ///
+ /// Registers the plugin's services in the host dependency injection container.
+ ///
+ /// The host's dependency injection service collection.
+ /// The host application configuration.
+ ///
+ ///
+ /// This method is called while the host application is being configured,
+ /// before the application is built.
+ ///
+ ///
+ /// Plugins should register all services required by their functionality
+ /// through this method rather than creating their own dependency injection
+ /// container.
+ ///
+ ///
+ void ConfigureServices(
+ IServiceCollection services,
+ IConfiguration configuration);
+
+ ///
+ /// Performs an optional health check for the plugin.
+ ///
+ /// The root service provider of the host application.
+ ///
+ /// true when the plugin is currently able to serve requests;
+ /// otherwise, false.
+ ///
+ ///
+ ///
+ /// The host may invoke this method as part of its health endpoint.
+ /// Plugins can resolve the services they require from
+ /// to verify the availability of their
+ /// dependencies.
+ ///
+ ///
+ /// A plugin should return false when a required dependency is
+ /// unavailable, such as when its database or external service cannot
+ /// currently be reached.
+ ///
+ ///
+ /// The default implementation reports the plugin as healthy. Plugins
+ /// that do not require custom health validation therefore do not need
+ /// to implement this member.
+ ///
+ ///
+ Task CheckHealthAsync(IServiceProvider services) =>
+ Task.FromResult(true);
+
+ ///
+ /// Gets the optional ASP.NET Core middleware type contributed by the plugin.
+ ///
+ ///
+ ///
+ /// When specified, the host inserts the middleware into its request
+ /// processing pipeline at the plugin middleware slot.
+ ///
+ ///
+ /// The middleware type must follow the conventional ASP.NET Core middleware
+ /// pattern, including a constructor accepting
+ /// and an InvokeAsync method accepting .
+ /// Additional dependencies may be supplied through dependency injection.
+ ///
+ ///
+ /// The default value is null, indicating that the plugin does not
+ /// contribute middleware.
+ ///
+ ///
+ Type? MiddlewareType => null;
+
+ ///
+ /// Gets the OpenAPI security schemes contributed by the plugin.
+ ///
+ ///
+ /// A read-only dictionary keyed by security scheme name.
+ ///
+ ///
+ ///
+ /// Security schemes returned by this method are exposed by the host as
+ /// part of its Swagger/OpenAPI security metadata.
+ ///
+ ///
+ /// Plugins that do not contribute security schemes can rely on the default
+ /// empty collection.
+ ///
+ ///
+ IReadOnlyDictionary GetSecuritySchemes() =>
+ new Dictionary();
+}
diff --git a/src/Plugins/Solutions/DevTokens/DevTokens.csproj b/src/Plugins/Solutions/DevTokens/DevTokens.csproj
new file mode 100644
index 0000000..34c0378
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/DevTokens.csproj
@@ -0,0 +1,22 @@
+
+
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs
new file mode 100644
index 0000000..a2023f4
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs
@@ -0,0 +1,104 @@
+using DevTokens.DeveloperTokens;
+using DevTokens.Options;
+using AuthKit.Plugins.Abstractions;
+using FluentValidation;
+using DevTokens.Interfaces;
+using DevTokens.Middleware;
+using DevTokens.Repositories;
+using DevTokens.Security;
+using DevTokens.Services;
+using DevTokens.UseCase.Commands.Requests;
+using DevTokens.UseCase.Commands.Validations;
+using Marten;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DevTokens;
+
+///
+/// AuthKit plugin responsible for issuing, validating, and authorizing developer tokens
+/// used for SDK access.
+///
+///
+///
+/// The plugin provides the application services required to manage the developer token
+/// lifecycle, including token creation, validation, persistence, and scope-based authorization.
+///
+///
+/// Signing keys and token to key bindings are provided by the Host core through
+/// dependency injection. The plugin does not own or manage the underlying signing-key
+/// infrastructure.
+///
+///
+public sealed class DevTokensPlugin : IAuthKitPlugin
+{
+ ///
+ /// Gets the unique name of the plugin.
+ ///
+ public string Name => "DevTokens";
+
+ ///
+ /// Gets the current version of the plugin.
+ ///
+ public string Version => "1.0.0";
+
+ ///
+ /// Gets a description of the functionality provided by the plugin.
+ ///
+ public string Description =>
+ "Issues and validates developer tokens for SDK access.";
+
+ ///
+ /// Registers developer token services, repositories, validators,
+ /// and authorization components in the dependency injection container.
+ ///
+ /// The used to register plugin services.
+ /// Application configuration used to configure .
+ public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("AuthKit"));
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddScoped, CreateDeveloperTokenValidator>();
+ services.AddScoped, DeleteTokenCommandValidator>();
+ }
+
+ public Type MiddlewareType => typeof(DeveloperTokenMiddleware);
+
+ public async Task CheckHealthAsync(IServiceProvider services)
+ {
+ var store = services.GetService();
+ if (store is null) return false;
+
+ try
+ {
+ await using var session = store.LightweightSession();
+ await session.Query().Take(1).ToListAsync();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public IReadOnlyDictionary GetSecuritySchemes() =>
+ new Dictionary
+ {
+ ["X-Developer-Token"] = new()
+ {
+ Name = "X-Developer-Token",
+ Type = AuthKitSecuritySchemeType.ApiKey,
+ In = AuthKitApiKeyLocation.Header,
+ Description = "AuthKit developer JWT token"
+ }
+ };
+}
diff --git a/Application/Options/AuthKitOptions.cs b/src/Plugins/Solutions/DevTokens/Options/AuthKitOptions.cs
similarity index 91%
rename from Application/Options/AuthKitOptions.cs
rename to src/Plugins/Solutions/DevTokens/Options/AuthKitOptions.cs
index 50202bf..43664cb 100644
--- a/Application/Options/AuthKitOptions.cs
+++ b/src/Plugins/Solutions/DevTokens/Options/AuthKitOptions.cs
@@ -1,4 +1,4 @@
-namespace Application.Options;
+namespace DevTokens.Options;
///
/// Configuration options for the AuthKit module.
diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/CreateTokenRequest.cs b/src/Plugins/Solutions/DevTokens/src/DTO/CreateTokenRequest.cs
new file mode 100644
index 0000000..608383a
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/DTO/CreateTokenRequest.cs
@@ -0,0 +1,18 @@
+namespace DevTokens.DTO;
+
+///
+/// Represents request to create new developer token.
+///
+/// The name assigned to the developer token.
+/// A description of the developer token and its intended use.
+/// The scopes granted to the developer token.
+///
+/// The optional lifetime of the developer token in days.
+/// A null value indicates that the default lifetime should be used.
+///
+public record CreateTokenRequest(
+ string Name,
+ string Description,
+ IEnumerable Scopes,
+ int? LifetimeDays
+);
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenCreated.cs b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenCreated.cs
new file mode 100644
index 0000000..b3b6291
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenCreated.cs
@@ -0,0 +1,36 @@
+namespace DevTokens.DTO;
+
+///
+/// Represents the result of creating a developer token.
+///
+///
+///
+/// Contains the credentials generated for the newly created developer token,
+/// including its short API key and signed JWT.
+///
+///
+/// Includes the entity containing the persisted
+/// token metadata and configuration.
+///
+///
+public class DeveloperTokenCreated
+{
+ ///
+ /// Gets or sets the short API key used as a public identifier
+ /// for the developer token.
+ ///
+ /// rk_live_xxx
+ public string ShortKey { get; set; } = null!;
+
+ ///
+ /// Gets or sets the signed JWT containing developer token claims
+ /// and expiration information.
+ ///
+ public string Jwt { get; set; } = null!;
+
+ ///
+ /// Gets or sets the developer token entity associated with the
+ /// generated credentials.
+ ///
+ public DeveloperToken Token { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenDto.cs b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenDto.cs
new file mode 100644
index 0000000..63f89ce
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenDto.cs
@@ -0,0 +1,73 @@
+namespace DevTokens.DTO;
+
+///
+/// Represents the data transfer object for developer token.
+///
+///
+///
+/// Exposes the public token metadata required by API consumers without
+/// exposing the underlying domain model.
+///
+///
+/// Provides calculated expiration state and a factory method for mapping
+/// domain entity to its DTO representation.
+///
+///
+public record DeveloperTokenDto
+{
+ ///
+ /// Gets the unique identifier of the developer token.
+ ///
+ public Guid Id { get; init; }
+
+ ///
+ /// Gets the unique identifier of the developer that owns the token.
+ ///
+ public Guid DeveloperId { get; init; }
+
+ ///
+ /// Gets the display name of the developer token.
+ ///
+ public string Name { get; init; } = null!;
+
+ ///
+ /// Gets the scopes granted to the developer token.
+ ///
+ public IReadOnlyList Scopes { get; init; } = [];
+
+ ///
+ /// Gets the date and time when the developer token was created.
+ ///
+ public DateTimeOffset CreatedAt { get; init; }
+
+ ///
+ /// Gets the date and time when the developer token expires,
+ /// or null when the token does not expire.
+ ///
+ public DateTimeOffset? ExpiresAt { get; init; }
+
+ ///
+ /// Gets value indicating whether the developer token has expired.
+ ///
+ public bool IsExpired =>
+ ExpiresAt.HasValue && DateTimeOffset.UtcNow > ExpiresAt.Value;
+
+ ///
+ /// Creates from domain token.
+ ///
+ /// The developer token domain entity to map.
+ /// A DTO containing the public representation of the developer token.
+ public static DeveloperTokenDto FromDomain(DeveloperToken token) =>
+ new()
+ {
+ Id = token.Id,
+ DeveloperId = token.DeveloperId,
+ Name = token.Name.ToString(),
+ Scopes = token.Scopes
+ .Select(s => s.Value)
+ .ToList()
+ .AsReadOnly(),
+ CreatedAt = token.Lifetime.CreatedAt,
+ ExpiresAt = token.Lifetime.ExpiresAt
+ };
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenPairDto.cs b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenPairDto.cs
new file mode 100644
index 0000000..2bf2d73
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/DTO/DeveloperTokenPairDto.cs
@@ -0,0 +1,17 @@
+namespace DevTokens.DTO;
+
+///
+/// Represents the generated credentials for developer token.
+///
+///
+///
+/// Contains both the short API key used to identify the token and the
+/// signed JWT used for authenticated requests.
+///
+///
+/// The public identifier used as an API key in requests.
+/// The signed JSON Web Token containing the developer token claims.
+public record DeveloperTokenPairDto(
+ string ShortKey,
+ string Jwt
+);
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/DTO/VerifyTokenRequest.cs b/src/Plugins/Solutions/DevTokens/src/DTO/VerifyTokenRequest.cs
new file mode 100644
index 0000000..eb54533
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/DTO/VerifyTokenRequest.cs
@@ -0,0 +1,11 @@
+namespace DevTokens.DTO;
+
+///
+/// Represents a request to verify the developer token using its short key.
+///
+///
+/// The developer token key to verify.
+///
+public record VerifyTokenRequest(
+ string Key
+);
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/DeveloperToken.cs b/src/Plugins/Solutions/DevTokens/src/DeveloperToken.cs
new file mode 100644
index 0000000..d707b3e
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/DeveloperToken.cs
@@ -0,0 +1,144 @@
+using Core.TokenKeyBindings.Services;
+using DevTokens.ValueObject;
+
+namespace DevTokens;
+
+///
+/// Represents a developer issued token with name, scopes, signing key bindings, and lifetime.
+///
+///
+///
+/// A developer token identifies a specific developer and contains the scopes
+/// and signing key bindings associated with that token.
+/// The token lifetime defines when the token was created and, optionally, when it expires.
+///
+///
+/// Token modifications return new instances instead of mutating the existing token,
+/// allowing token state to be treated immutably.
+///
+///
+public record DeveloperToken
+{
+ ///
+ /// Gets the unique identifier of the developer token.
+ ///
+ public Guid Id { get; } = Guid.NewGuid();
+
+ ///
+ /// Gets the unique identifier of the developer who owns the token.
+ ///
+ public Guid DeveloperId { get; init; }
+
+ ///
+ /// Gets the name assigned to the token.
+ ///
+ public TokenName Name { get; init; } = new("default");
+
+ ///
+ /// Gets the scopes granted to the token.
+ ///
+ public IReadOnlyList Scopes { get; init; } = [];
+
+ ///
+ /// Gets the signing key bindings associated with the token.
+ ///
+ public IReadOnlyList KeyBindings { get; init; } = [];
+
+ ///
+ /// Gets the lifetime information for the token.
+ ///
+ public TokenLifetime Lifetime { get; init; } = new(DateTimeOffset.UtcNow);
+
+ ///
+ /// Creates new developer token.
+ ///
+ /// The unique identifier of the developer who owns the token.
+ /// The name assigned to the token.
+ /// The scopes granted to the token.
+ ///
+ /// Optional token lifetime. If null, the token does not expire.
+ ///
+ /// A newly created .
+ public static DeveloperToken Create(
+ Guid developerId,
+ TokenName name,
+ IEnumerable scopes,
+ TimeSpan? lifetime = null)
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var tokenLifetime = new TokenLifetime(
+ now,
+ lifetime.HasValue ? now.Add(lifetime.Value) : null
+ );
+
+ return new DeveloperToken
+ {
+ DeveloperId = developerId,
+ Name = name,
+ Scopes = scopes.ToList().AsReadOnly(),
+ Lifetime = tokenLifetime
+ };
+ }
+
+ ///
+ /// Renews the token with a new lifetime starting from the current time.
+ ///
+ /// The duration for which the renewed token remains valid.
+ /// A new with the renewed lifetime.
+ public DeveloperToken Renew(TimeSpan extension)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var newLifetime = new TokenLifetime(now, now.Add(extension));
+
+ return this with { Lifetime = newLifetime };
+ }
+
+ ///
+ /// Adds scope to the token.
+ ///
+ /// The scope to add.
+ /// A new containing the additional scope.
+ public DeveloperToken AddScope(TokenScope scope)
+ {
+ var newScopes = Scopes
+ .Append(scope)
+ .ToList()
+ .AsReadOnly();
+
+ return this with { Scopes = newScopes };
+ }
+
+ ///
+ /// Adds signing key binding to the token.
+ ///
+ /// The signing key binding to add.
+ /// A new containing the additional key binding.
+ public DeveloperToken AddKeyBinding(TokenKeyBinding binding)
+ {
+ var newBindings = KeyBindings
+ .Append(binding)
+ .ToList()
+ .AsReadOnly();
+
+ return this with { KeyBindings = newBindings };
+ }
+
+ ///
+ /// Replaces an existing signing key binding with new binding.
+ ///
+ /// The identifier of the signing key binding to replace.
+ /// The replacement signing key binding.
+ /// A new with the matching key binding replaced.
+ public DeveloperToken ReplaceKeyBinding(
+ string signingKeyId,
+ TokenKeyBinding newBinding)
+ {
+ var newBindings = KeyBindings
+ .Select(k => k.SigningKeyId == signingKeyId ? newBinding : k)
+ .ToList()
+ .AsReadOnly();
+
+ return this with { KeyBindings = newBindings };
+ }
+}
\ No newline at end of file
diff --git a/Application/Features/DeveloperTokens/DeveloperTokenPrincipal.cs b/src/Plugins/Solutions/DevTokens/src/DeveloperTokenPrincipal.cs
similarity index 92%
rename from Application/Features/DeveloperTokens/DeveloperTokenPrincipal.cs
rename to src/Plugins/Solutions/DevTokens/src/DeveloperTokenPrincipal.cs
index 0d1b16f..91de162 100644
--- a/Application/Features/DeveloperTokens/DeveloperTokenPrincipal.cs
+++ b/src/Plugins/Solutions/DevTokens/src/DeveloperTokenPrincipal.cs
@@ -1,4 +1,4 @@
-namespace Application.Features.DeveloperTokens;
+namespace DevTokens;
///
/// Represents the authenticated principal of a developer token.
diff --git a/Domain/Features/DeveloperTokens/DeveloperTokenLimitExceededException.cs b/src/Plugins/Solutions/DevTokens/src/Exceptions/DeveloperTokenLimitExceededException.cs
similarity index 70%
rename from Domain/Features/DeveloperTokens/DeveloperTokenLimitExceededException.cs
rename to src/Plugins/Solutions/DevTokens/src/Exceptions/DeveloperTokenLimitExceededException.cs
index 8f6b43f..d0be691 100644
--- a/Domain/Features/DeveloperTokens/DeveloperTokenLimitExceededException.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Exceptions/DeveloperTokenLimitExceededException.cs
@@ -1,7 +1,10 @@
-namespace Domain.Features.DeveloperTokens;
+using Core;
+
+namespace DevTokens.Exceptions;
///
-/// Exception thrown when a developer exceeds the allowed number of tokens.
+/// Represents domain exception raised when developer exceeds the
+/// maximum number of developer tokens allowed.
///
/// The unique identifier of the developer who exceeded the limit.
/// The maximum number of tokens allowed per developer.
diff --git a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenManager.cs b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenManager.cs
similarity index 93%
rename from Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenManager.cs
rename to src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenManager.cs
index 8086a95..4ccd93b 100644
--- a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenManager.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenManager.cs
@@ -1,7 +1,6 @@
-using Application.Features.DeveloperTokens.DTO;
-using Domain.Features.DeveloperTokens;
+using DevTokens.DTO;
-namespace Application.Features.DeveloperTokens.Interfaces;
+namespace DevTokens.Interfaces;
///
/// Provides operations for managing developer tokens.
diff --git a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenService.cs b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenService.cs
similarity index 73%
rename from Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenService.cs
rename to src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenService.cs
index 038b33b..8eef4c1 100644
--- a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenService.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenService.cs
@@ -1,7 +1,7 @@
-using Application.Features.DeveloperTokens.DTO;
-using Domain.Features.DeveloperTokens;
+using DevTokens.DeveloperTokens;
+using DevTokens.DTO;
-namespace Application.Features.DeveloperTokens.Interfaces;
+namespace DevTokens.Interfaces;
///
/// Service responsible for generating JWT tokens for developer tokens.
@@ -15,7 +15,7 @@ namespace Application.Features.DeveloperTokens.Interfaces;
public interface IDeveloperTokenService
{
///
- /// Generates a signed JWT string for the specified .
+ /// Generates signed JWT string for the specified .
///
/// The developer token entity for which to generate a JWT.
/// A containing the signed JWT string.
diff --git a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenValidator.cs b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenValidator.cs
similarity index 92%
rename from Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenValidator.cs
rename to src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenValidator.cs
index a4d39c2..11f75a5 100644
--- a/Application/Features/DeveloperTokens/Interfaces/IDeveloperTokenValidator.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Interfaces/IDeveloperTokenValidator.cs
@@ -1,4 +1,4 @@
-namespace Application.Features.DeveloperTokens.Interfaces;
+namespace DevTokens.Interfaces;
///
/// Interface for validating developer JWT tokens.
diff --git a/Infrastructure/Restful/Middleware/DeveloperTokenMiddleware.cs b/src/Plugins/Solutions/DevTokens/src/Middleware/DeveloperTokenMiddleware.cs
similarity index 88%
rename from Infrastructure/Restful/Middleware/DeveloperTokenMiddleware.cs
rename to src/Plugins/Solutions/DevTokens/src/Middleware/DeveloperTokenMiddleware.cs
index 1f4c32e..575a58b 100644
--- a/Infrastructure/Restful/Middleware/DeveloperTokenMiddleware.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Middleware/DeveloperTokenMiddleware.cs
@@ -1,9 +1,9 @@
using System.Security.Claims;
-using Application.Features.DeveloperTokens.Interfaces;
+using DevTokens.Interfaces;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
-namespace Infrastructure.Restful.Middleware;
+namespace DevTokens.Middleware;
///
/// Middleware responsible for validating "DeveloperToken" headers and enriching .
@@ -12,9 +12,9 @@ namespace Infrastructure.Restful.Middleware;
///
/// - Checks for the presence of the "X-Developer-Token" header.
/// - Validates the token using .
-/// - Adds a to containing the developer ID, name, and scopes.
+/// - Adds to containing the developer ID, name, and scopes.
/// - Logs validation success or failure for observability.
-/// - Does not short-circuit the request; the pipeline continues regardless of token validity.
+/// - Does not short circuit the request, the pipeline continues regardless of token validity.
///
///
public class DeveloperTokenMiddleware(RequestDelegate next, ILogger logger)
diff --git a/src/Plugins/Solutions/DevTokens/src/Repositories/DeveloperTokenRepository.cs b/src/Plugins/Solutions/DevTokens/src/Repositories/DeveloperTokenRepository.cs
new file mode 100644
index 0000000..f302d07
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Repositories/DeveloperTokenRepository.cs
@@ -0,0 +1,74 @@
+using Marten;
+
+namespace DevTokens.Repositories;
+
+///
+/// Repository for managing persistence using Marten.
+///
+///
+///
+/// Provides persistence operations for developer tokens through a Marten
+/// .
+///
+///
+/// - Stores and updates developer tokens in the document database.
+/// - Deletes developer tokens by their unique identifier.
+/// - Retrieves individual tokens by ID.
+/// - Retrieves all tokens belonging to a specific developer.
+/// - Supports cancellation for all asynchronous database operations.
+///
+///
+public class DeveloperTokenRepository(IDocumentSession session) : IDeveloperTokenRepository
+{
+ ///
+ /// Saves the specified developer token to the document database.
+ ///
+ /// The developer token to persist.
+ /// A token that can be used to cancel the operation.
+ /// A task representing the asynchronous persistence operation.
+ public async Task SaveAsync(
+ DeveloperToken token,
+ CancellationToken ct = default)
+ {
+ session.Store(token);
+ await session.SaveChangesAsync(ct);
+ }
+
+ ///
+ /// Deletes the developer token with the specified identifier.
+ ///
+ /// The unique identifier of the token to delete.
+ /// A token that can be used to cancel the operation.
+ /// A task representing the asynchronous deletion operation.
+ public async Task DeleteAsync(
+ Guid id,
+ CancellationToken ct = default)
+ {
+ session.Delete(id);
+ await session.SaveChangesAsync(ct);
+ }
+
+ ///
+ /// Retrieves developer token by its unique identifier.
+ ///
+ /// The unique identifier of the token.
+ /// A token that can be used to cancel the operation.
+ /// The matching when found otherwise, null.
+ public async Task GetByIdAsync(
+ Guid id,
+ CancellationToken ct = default)
+ => await session.LoadAsync(id, ct);
+
+ ///
+ /// Retrieves all developer tokens associated with the specified developer.
+ ///
+ /// The unique identifier of the developer.
+ /// A token that can be used to cancel the operation.
+ /// A readonly list containing all tokens associated with the specified developer.
+ public async Task> GetByDeveloperIdAsync(
+ Guid developerId,
+ CancellationToken ct = default)
+ => await session.Query()
+ .Where(x => x.DeveloperId == developerId)
+ .ToListAsync(ct);
+}
\ No newline at end of file
diff --git a/Domain/Features/DeveloperTokens/Repositories/IDeveloperTokenRepository.cs b/src/Plugins/Solutions/DevTokens/src/Repositories/IDeveloperTokenRepository.cs
similarity index 97%
rename from Domain/Features/DeveloperTokens/Repositories/IDeveloperTokenRepository.cs
rename to src/Plugins/Solutions/DevTokens/src/Repositories/IDeveloperTokenRepository.cs
index f92642d..a06ad82 100644
--- a/Domain/Features/DeveloperTokens/Repositories/IDeveloperTokenRepository.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Repositories/IDeveloperTokenRepository.cs
@@ -1,4 +1,4 @@
-namespace Domain.Features.DeveloperTokens.Repositories;
+namespace DevTokens.Repositories;
///
/// Repository interface for managing entities.
diff --git a/src/Plugins/Solutions/DevTokens/src/Restful/DeveloperTokensController.cs b/src/Plugins/Solutions/DevTokens/src/Restful/DeveloperTokensController.cs
new file mode 100644
index 0000000..431121a
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Restful/DeveloperTokensController.cs
@@ -0,0 +1,201 @@
+using System.Security.Claims;
+using DevTokens.DTO;
+using DevTokens.UseCase.Commands.Requests;
+using DevTokens.UseCase.Queries.Requests;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Swashbuckle.AspNetCore.Annotations;
+using Wolverine;
+
+namespace DevTokens.Restful;
+
+///
+/// Provides REST API endpoints for managing developer tokens.
+///
+///
+///
+/// The controller exposes operations for creating, deleting, and retrieving
+/// developer tokens belonging to the authenticated user.
+///
+///
+/// - Uses Wolverine to dispatch commands and queries to the corresponding application handlers.
+/// - Requires an authenticated user with the User role for all endpoints.
+/// - Extracts the authenticated developer identifier from the claim.
+/// - Documents the available operations and responses using Swagger annotations.
+///
+///
+[ApiController]
+[Route("sdk/developer-tokens")]
+[SwaggerTag("Operations related to developer tokens")]
+public class DeveloperTokensController(
+ IMessageBus messageBus,
+ ILogger logger) : ControllerBase
+{
+ ///
+ /// Gets the unique identifier of the currently authenticated user.
+ ///
+ ///
+ /// The parsed user identifier, or null when the
+ /// claim is missing or invalid.
+ ///
+ private Guid? GetUserId() =>
+ Guid.TryParse(
+ User.FindFirstValue(ClaimTypes.NameIdentifier),
+ out var id)
+ ? id
+ : null;
+
+ ///
+ /// Registers a new developer token for the authenticated user.
+ ///
+ ///
+ /// The request containing the token name, description, scopes,
+ /// and optional lifetime in days.
+ ///
+ ///
+ /// An HTTP 200 response containing the generated JWT, short API key,
+ /// token identifier, developer identifier, scopes, and lifetime.
+ /// Returns HTTP 401 when the authenticated user identifier cannot be resolved.
+ ///
+ [HttpPost]
+ [Authorize(Roles = "User")]
+ [SwaggerOperation(
+ Summary = "Registers new developer token",
+ Description = "Creates developer token for the authenticated user. Requires token name, description, scopes, and optional lifetime in days.")]
+ [SwaggerResponse(
+ 200,
+ "Returns the created developer token",
+ typeof(DeveloperTokenCreated))]
+ [SwaggerResponse(401, "Unauthorized")]
+ public async Task Register(
+ [FromBody] CreateTokenRequest request)
+ {
+ var userId = GetUserId();
+
+ if (userId is null)
+ return Unauthorized();
+
+ var lifetime = request.LifetimeDays.HasValue
+ ? TimeSpan.FromDays(request.LifetimeDays.Value)
+ : (TimeSpan?)null;
+
+ var command = new CreateDeveloperTokenCommand(
+ userId.Value,
+ request.Name,
+ request.Description,
+ request.Scopes,
+ lifetime);
+
+ var result =
+ await messageBus.InvokeAsync(command);
+
+ logger.LogInformation(
+ "Token created: TokenId={TokenId}, DeveloperId={DeveloperId}",
+ result.Token.Id,
+ result.Token.DeveloperId);
+
+ return Ok(new
+ {
+ jwt = result.Jwt,
+ key = result.ShortKey,
+ id = result.Token.Id,
+ developerId = result.Token.DeveloperId,
+ scopes = result.Token.Scopes,
+ lifetime = result.Token.Lifetime
+ });
+ }
+
+ ///
+ /// Deletes an existing developer token by its unique identifier.
+ ///
+ /// The unique identifier of the developer token to delete.
+ ///
+ /// An HTTP 200 response when the token is successfully deleted.
+ ///
+ [HttpDelete("{tokenId:guid}")]
+ [Authorize(Roles = "User")]
+ [SwaggerOperation(
+ Summary = "Deletes a developer token",
+ Description = "Deletes the specified developer token by its unique ID.")]
+ [SwaggerResponse(200, "Token deleted successfully")]
+ [SwaggerResponse(401, "Unauthorized")]
+ [SwaggerResponse(404, "Token not found")]
+ public async Task Delete(Guid tokenId)
+ {
+ var command = new DeleteTokenCommand(tokenId);
+
+ await messageBus.InvokeAsync(command);
+
+ logger.LogInformation(
+ "Token deleted successfully: TokenId={TokenId}",
+ tokenId);
+
+ return Ok("Token deleted successfully");
+ }
+
+ ///
+ /// Retrieves all developer tokens belonging to the authenticated user.
+ ///
+ ///
+ /// An HTTP 200 response containing a read-only list of
+ /// instances.
+ /// Returns HTTP 401 when the authenticated user identifier cannot be resolved.
+ ///
+ [HttpGet]
+ [Authorize(Roles = "User")]
+ [SwaggerOperation(
+ Summary = "Retrieves all developer tokens",
+ Description = "Returns a read-only list of developer tokens for the authenticated user.")]
+ [SwaggerResponse(
+ 200,
+ "List of developer tokens",
+ typeof(IReadOnlyList))]
+ [SwaggerResponse(401, "Unauthorized")]
+ public async Task GetAll()
+ {
+ var userId = GetUserId();
+
+ if (userId is null)
+ return Unauthorized();
+
+ var query = new GetDeveloperTokensQuery(userId.Value);
+ var result = await messageBus.InvokeAsync>(query);
+
+ logger.LogInformation("Retrieved {Count} tokens for DeveloperId={DeveloperId}", result.Count, userId);
+ return Ok(result);
+ }
+
+ ///
+ /// Retrieves specific developer token by its unique identifier.
+ ///
+ /// The unique identifier of the developer token to retrieve.
+ ///
+ /// An HTTP 200 response containing the requested
+ /// , or HTTP 404 when the token does not exist.
+ ///
+ [HttpGet("{tokenId:guid}")]
+ [Authorize(Roles = "User")]
+ [SwaggerOperation(
+ Summary = "Retrieves a developer token by ID",
+ Description = "Returns a single developer token by its unique ID.")]
+ [SwaggerResponse(
+ 200,
+ "Developer token details",
+ typeof(DeveloperTokenDto))]
+ [SwaggerResponse(401, "Unauthorized")]
+ [SwaggerResponse(404, "Token not found")]
+ public async Task GetById(Guid tokenId)
+ {
+ var query = new GetDeveloperTokenByIdQuery(tokenId);
+
+ var result =
+ await messageBus.InvokeAsync(query);
+
+ if (result is null)
+ return NotFound();
+
+ logger.LogInformation("Token retrieved successfully: TokenId={TokenId}", tokenId);
+ return Ok(result);
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/Restful/TokenLifecycleController.cs b/src/Plugins/Solutions/DevTokens/src/Restful/TokenLifecycleController.cs
new file mode 100644
index 0000000..59b6dd5
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Restful/TokenLifecycleController.cs
@@ -0,0 +1,69 @@
+using DevTokens.DTO;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace DevTokens.Restful;
+
+///
+/// Provides REST API endpoints for developer token lifecycle operations.
+///
+///
+/// Exposes operations for rotating, revoking, and verifying developer tokens.
+///
+/// - Supports revoking an existing token and generating a replacement token.
+/// - Supports verification of developer token credentials against persisted token data.
+/// - Requires the User role for token rotation and revocation operations.
+///
+///
+[ApiController]
+[Route("sdk/tokens")]
+[SwaggerTag("Developer token lifecycle operations: rotate, revoke, verify")]
+public class TokenLifecycleController : ControllerBase
+{
+ ///
+ /// Revokes an existing developer token and generates a replacement token.
+ ///
+ /// The unique identifier of the developer token to revoke and rotate.
+ /// A token that can be used to cancel the asynchronous operation.
+ ///
+ /// An HTTP 200 response containing the newly generated developer token,
+ /// including its JWT and short API key.
+ ///
+ [HttpPost("{tokenId:guid}/revoke-rotate")]
+ [Authorize(Roles = "User")]
+ [SwaggerOperation(
+ Summary = "Revoke and rotate token",
+ Description = "Revokes the specified token and generates a new JWT and shortKey.")]
+ [SwaggerResponse(
+ 200,
+ "Token rotated successfully",
+ typeof(DeveloperTokenCreated))]
+ [SwaggerResponse(401, "Unauthorized")]
+ [SwaggerResponse(404, "Token not found")]
+ public Task RevokeAndRotate(Guid tokenId, CancellationToken ct)
+ {
+ return Task.FromResult(null!);
+ }
+
+ ///
+ /// Verifies a developer token against its persisted token data.
+ ///
+ ///
+ /// The request containing the JWT and short API key to verify.
+ ///
+ ///
+ /// An HTTP 200 response containing true when the supplied token
+ /// is valid; otherwise false.
+ ///
+ [HttpPost("verify")]
+ [SwaggerOperation(
+ Summary = "Verify token validity",
+ Description = "Verifies that the provided JWT matches the stored token hash.")]
+ [SwaggerResponse(200, "Verification result", typeof(bool))]
+ [SwaggerResponse(401, "Unauthorized")]
+ public IActionResult Verify([FromBody] VerifyTokenRequest request)
+ {
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/Infrastructure/Restful/Controllers/VirtualMachineController.cs b/src/Plugins/Solutions/DevTokens/src/Restful/VirtualMachineController.cs
similarity index 94%
rename from Infrastructure/Restful/Controllers/VirtualMachineController.cs
rename to src/Plugins/Solutions/DevTokens/src/Restful/VirtualMachineController.cs
index 479078b..fd7f491 100644
--- a/Infrastructure/Restful/Controllers/VirtualMachineController.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Restful/VirtualMachineController.cs
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-namespace Infrastructure.Restful.Controllers;
+namespace DevTokens.DeveloperTokens.Restful;
[ApiController]
[Route("sdk/v1/function/vm")]
diff --git a/Infrastructure/Security/DeveloperScope/DeveloperScopeHandler.cs b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeHandler.cs
similarity index 94%
rename from Infrastructure/Security/DeveloperScope/DeveloperScopeHandler.cs
rename to src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeHandler.cs
index 2ca9294..d24cdd6 100644
--- a/Infrastructure/Security/DeveloperScope/DeveloperScopeHandler.cs
+++ b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeHandler.cs
@@ -1,9 +1,9 @@
using Microsoft.AspNetCore.Authorization;
-namespace Infrastructure.Security.DeveloperScope;
+namespace DevTokens.Security;
///
-/// Authorization handler that verifies if the user possesses the required scope in a developer token.
+/// Authorization handler that verifies if the user possesses the required scope in developer token.
///
///
///
@@ -28,9 +28,9 @@ protected override Task HandleRequirementAsync(AuthorizationHandlerContext conte
if (devIdentity == null) return Task.CompletedTask;
var scopes = devIdentity.FindAll("scope").Select(c => c.Value).ToList();
-
+
if (requirement.RequiredScope != null && scopes.Contains(requirement.RequiredScope))
context.Succeed(requirement);
return Task.CompletedTask;
}
-}
\ No newline at end of file
+}
diff --git a/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopePolicyProvider.cs b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopePolicyProvider.cs
new file mode 100644
index 0000000..53d45e5
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopePolicyProvider.cs
@@ -0,0 +1,68 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Options;
+
+namespace DevTokens.Security;
+
+///
+/// Provides authorization policies dynamically based on developer token scopes.
+///
+///
+///
+/// Policies whose names start with DeveloperScope: are generated dynamically
+/// and require the corresponding developer token scope.
+///
+///
+/// - Extracts the required scope from policy names using the DeveloperScope: prefix.
+/// - Creates an containing for the requested scope.
+/// - Delegates unknown policy names to the default .
+/// - Delegates default and fallback policy resolution to the underlying provider.
+///
+///
+public class DeveloperScopePolicyProvider(
+ IOptions options) : IAuthorizationPolicyProvider
+{
+ private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider =
+ new(options);
+
+ ///
+ /// Retrieves an authorization policy by name.
+ ///
+ /// The name of the authorization policy to retrieve.
+ ///
+ /// A task containing the resolved ,
+ /// or null when no matching policy exists.
+ ///
+ public Task GetPolicyAsync(string policyName)
+ {
+ if (!policyName.StartsWith("DeveloperScope:"))
+ return _fallbackPolicyProvider.GetPolicyAsync(policyName);
+
+ var scope = policyName.Split(':')[1];
+
+ var policy = new AuthorizationPolicyBuilder()
+ .AddRequirements(new DeveloperScopeRequirement
+ {
+ RequiredScope = scope
+ })
+ .Build();
+
+ return Task.FromResult(policy);
+ }
+
+ ///
+ /// Retrieves the application default authorization policy.
+ ///
+ /// A task containing the default .
+ public Task GetDefaultPolicyAsync()
+ => _fallbackPolicyProvider.GetDefaultPolicyAsync();
+
+ ///
+ /// Retrieves the application's fallback authorization policy.
+ ///
+ ///
+ /// A task containing the fallback ,
+ /// or null when no fallback policy is configured.
+ ///
+ public Task GetFallbackPolicyAsync()
+ => _fallbackPolicyProvider.GetFallbackPolicyAsync();
+}
diff --git a/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeRequirement.cs b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeRequirement.cs
new file mode 100644
index 0000000..5710c66
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Security/DeveloperScopeRequirement.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DevTokens.Security;
+
+///
+/// Represents an authorization requirement for specific developer token scope.
+///
+///
+///
+/// The requirement is evaluated by , which
+/// determines whether the current identity contains the required developer token scope.
+///
+///
+/// The required scope is assigned dynamically by
+/// when creating scope based authorization policies.
+///
+///
+public class DeveloperScopeRequirement : IAuthorizationRequirement
+{
+ ///
+ /// Gets or sets the scope that must be present in the developer token
+ /// for authorization to succeed.
+ ///
+ public string? RequiredScope { get; set; }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenManager.cs b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenManager.cs
new file mode 100644
index 0000000..1cabd66
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenManager.cs
@@ -0,0 +1,93 @@
+using DevTokens.DTO;
+using DevTokens.Interfaces;
+using DevTokens.Repositories;
+using DevTokens.ValueObject;
+
+namespace DevTokens.Services;
+
+///
+/// Manages the lifecycle of developer tokens.
+///
+///
+///
+/// Provides operations for creating, deleting, and retrieving developer tokens.
+/// Delegates token generation to
+/// and token persistence to .
+///
+///
+public class DeveloperTokenManager(
+ IDeveloperTokenService tokenService,
+ IDeveloperTokenRepository repository) : IDeveloperTokenManager
+{
+ ///
+ /// Creates a new developer token and generates its token credentials.
+ ///
+ /// The unique identifier of the developer who owns the token.
+ /// The name assigned to the token.
+ /// The scopes granted to the token.
+ ///
+ /// Optional duration for which the token remains valid. If null,
+ /// the token does not expire.
+ ///
+ /// Cancellation token for the asynchronous operation.
+ ///
+ /// A containing the created token,
+ /// its short API key, and signed JWT.
+ ///
+ public async Task CreateAsync(
+ Guid developerId,
+ string name,
+ IEnumerable scopes,
+ TimeSpan? lifetime = null,
+ CancellationToken ct = default)
+ {
+ var token = DeveloperToken.Create(
+ developerId,
+ name,
+ scopes.Select(s => (TokenScope)s),
+ lifetime
+ );
+
+ var tokenPair = await tokenService.GenerateToken(token);
+
+ return new DeveloperTokenCreated
+ {
+ Token = token,
+ ShortKey = tokenPair.ShortKey,
+ Jwt = tokenPair.Jwt
+ };
+ }
+
+ ///
+ /// Deletes developer token by its unique identifier.
+ ///
+ /// The unique identifier of the token to delete.
+ /// Cancellation token for the asynchronous operation.
+ /// A task representing the asynchronous deletion operation.
+ public async Task DeleteAsync(
+ Guid tokenId,
+ CancellationToken ct = default) =>
+ await repository.DeleteAsync(tokenId, ct);
+
+ ///
+ /// Retrieves all developer tokens belonging to the specified developer.
+ ///
+ /// The unique identifier of the developer.
+ /// Cancellation token for the asynchronous operation.
+ /// A readonly list containing the developer's tokens.
+ public async Task> GetByDeveloperAsync(
+ Guid developerId,
+ CancellationToken ct = default) =>
+ await repository.GetByDeveloperIdAsync(developerId, ct);
+
+ ///
+ /// Retrieves developer token by its unique identifier.
+ ///
+ /// The unique identifier of the token.
+ /// Cancellation token for the asynchronous operation.
+ /// The matching , or null if the token does not exist.
+ public async Task GetByIdAsync(
+ Guid tokenId,
+ CancellationToken ct = default) =>
+ await repository.GetByIdAsync(tokenId, ct);
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenService.cs b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenService.cs
new file mode 100644
index 0000000..9a00024
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenService.cs
@@ -0,0 +1,149 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using Core.KeyManagement.Interfaces;
+using Core.TokenKeyBindings.Interfaces;
+using DevTokens.DTO;
+using DevTokens.Interfaces;
+using Microsoft.IdentityModel.Tokens;
+
+namespace DevTokens.Services;
+
+///
+/// Service responsible for generating JWT credentials for developer tokens.
+///
+///
+///
+/// Uses the active signing key from to create
+/// signed JSON Web Tokens for developer tokens.
+///
+///
+/// Creates signing key binding between the developer token and the RSA key
+/// used to sign its JWT through .
+///
+///
+/// Generates both the signed JWT and short API key identifier. Tokens are
+/// distinguished as temporary or permanent based on whether an expiration
+/// date is configured.
+///
+///
+public class DeveloperTokenService(
+ IJwtKeyStore jwtKeyStore,
+ IKeyBindingService keyBindingService) : IDeveloperTokenService
+{
+ ///
+ /// Generates JWT credentials for the specified developer token.
+ ///
+ /// The developer token for which credentials are generated.
+ /// A containing the generated short API key and signed JWT.
+ public async Task GenerateToken(DeveloperToken token)
+ {
+ var creds = GetSigningCredentials();
+ var claims = BuildClaims(token);
+
+ var jwt = CreateJwtToken(claims, token.Lifetime.ExpiresAt, creds);
+ await CreateKeyBinding(token.Id, creds.Key.KeyId!);
+
+ var handler = new JwtSecurityTokenHandler();
+ return new DeveloperTokenPairDto(
+ GenerateShortKey(token),
+ handler.WriteToken(jwt));
+ }
+
+ ///
+ /// Retrieves the active JWT signing credentials.
+ ///
+ /// The active signing credentials with a valid key identifier.
+ /// Thrown when the active signing key does not have a key identifier.
+ private SigningCredentials GetSigningCredentials()
+ {
+ var creds = jwtKeyStore.GetActiveSigningCredentials();
+ return string.IsNullOrEmpty(creds.Key.KeyId)
+ ? throw new InvalidOperationException(
+ "SigningCredentials must have a KeyId set.")
+ : creds;
+ }
+
+ ///
+ /// Creates binding between the developer token and its signing key.
+ ///
+ /// The unique identifier of the developer token.
+ /// The identifier of the signing key.
+ /// A task representing the asynchronous binding operation.
+ /// Thrown when the public JWK corresponding to the signing key cannot be found.
+ private async Task CreateKeyBinding(Guid tokenId, string signingKeyId)
+ {
+ var jwk = jwtKeyStore.GetPublicJwks()
+ .FirstOrDefault(k => k.Kid == signingKeyId)
+ ?? throw new InvalidOperationException(
+ $"No JWK for kid '{signingKeyId}'.");
+
+ await keyBindingService.CreateBindingAsync(
+ tokenId,
+ signingKeyId,
+ jwk.N);
+ }
+
+ ///
+ /// Creates a signed JWT for the specified claims and expiration time.
+ ///
+ /// Claims to include in the JWT.
+ /// Optional expiration date of the token.
+ /// Credentials used to sign the JWT.
+ /// A signed .
+ private static JwtSecurityToken CreateJwtToken(
+ IEnumerable claims,
+ DateTimeOffset? expiresAt,
+ SigningCredentials creds)
+ {
+ var expires = expiresAt?.UtcDateTime
+ ?? DateTime.UtcNow.AddYears(100);
+
+ return new JwtSecurityToken(
+ claims: claims,
+ expires: expires,
+ signingCredentials: creds);
+ }
+
+ ///
+ /// Builds the claims included in a developer token JWT.
+ ///
+ /// The developer token used as the source of the claims.
+ /// A sequence of JWT claims.
+ private static IEnumerable BuildClaims(DeveloperToken token)
+ {
+ yield return new Claim(JwtRegisteredClaimNames.Sub, token.DeveloperId.ToString());
+ yield return new Claim(JwtRegisteredClaimNames.Jti, token.Id.ToString());
+ yield return new Claim("name", token.Name);
+ yield return new Claim("type",
+ token.Lifetime.ExpiresAt.HasValue ? "temp" : "live");
+
+ foreach (var scope in token.Scopes)
+ {
+ yield return new Claim(
+ "scope",
+ scope.Value);
+ }
+ }
+
+ ///
+ /// Generates a short API key for the specified developer token.
+ ///
+ /// The developer token for which the key is generated.
+ ///
+ /// A randomly generated API key prefixed with rk_temp_ for
+ /// temporary tokens or rk_live_ for permanent tokens.
+ ///
+ private static string GenerateShortKey(DeveloperToken token)
+ {
+ var prefix = token.Lifetime.ExpiresAt.HasValue
+ ? "rk_temp_"
+ : "rk_live_";
+
+ var randomHex = Convert
+ .ToHexString(RandomNumberGenerator.GetBytes(16))
+ .ToLowerInvariant();
+
+ return $"{prefix}{randomHex}";
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenValidatorService.cs b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenValidatorService.cs
new file mode 100644
index 0000000..0b34b60
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/Services/DeveloperTokenValidatorService.cs
@@ -0,0 +1,195 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using Core.KeyManagement.Interfaces;
+using Core.TokenKeyBindings.Interfaces;
+using DevTokens.Interfaces;
+using Microsoft.Extensions.Logging;
+using Microsoft.IdentityModel.Tokens;
+
+namespace DevTokens.Services;
+
+///
+/// Validates developer token JWTs using the configured JWT key store
+/// and token to signing key bindings.
+///
+///
+///
+/// Extracts the token identifier (jti) and signing key identifier
+/// (kid) from the JWT and verifies that corresponding key binding exists.
+///
+///
+/// Validates the JWT signature and lifetime using the signing credentials
+/// associated with the specified key identifier.
+///
+///
+/// When validation succeeds, creates
+/// containing the developer identifier, token name, and assigned scopes.
+///
+///
+public class DeveloperTokenValidatorService(
+ IJwtKeyStore jwtKeyStore,
+ IKeyBindingService keyBinding,
+ ILogger logger
+) : IDeveloperTokenValidator
+{
+ private readonly JwtSecurityTokenHandler _handler = new();
+
+ ///
+ /// Validates developer token JWT and builds its authenticated principal.
+ ///
+ /// The JWT to validate.
+ ///
+ /// A when the token is valid;
+ /// otherwise, null.
+ ///
+ public async Task ValidateAsync(string jwtToken)
+ {
+ if (string.IsNullOrWhiteSpace(jwtToken))
+ return null;
+
+ var jwt = ReadJwtToken(jwtToken);
+ if (jwt == null)
+ return null;
+
+ if (!TryGetTokenId(jwt, out var tokenId))
+ {
+ logger.LogWarning("[Validator] Missing or invalid jti");
+ return null;
+ }
+
+ var kid = jwt.Header.Kid;
+ if (string.IsNullOrEmpty(kid))
+ {
+ logger.LogWarning("[Validator] Missing kid in token header");
+ return null;
+ }
+
+ if (!await IsBindingValid(tokenId, kid))
+ return null;
+
+ var signingCreds = jwtKeyStore.GetSigningCredentialsByKid(kid);
+ if (signingCreds != null)
+ return ValidateSignatureAndBuildPrincipal(jwtToken, signingCreds);
+
+ logger.LogWarning("[Validator] No signing creds for kid={Kid}", kid);
+ return null;
+
+ }
+
+ ///
+ /// Attempts to parse the supplied string as JWT.
+ ///
+ /// The JWT string to parse.
+ ///
+ /// The parsed , or null if parsing fails.
+ ///
+ private JwtSecurityToken? ReadJwtToken(string token)
+ {
+ try
+ {
+ return _handler.ReadJwtToken(token);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "[Validator] Token read failed");
+ return null;
+ }
+ }
+
+ ///
+ /// Attempts to extract the developer token identifier from the JWT jti claim.
+ ///
+ /// The parsed JWT.
+ /// The parsed token identifier.
+ ///
+ /// true when the jti claim contains a valid ;
+ /// otherwise, false.
+ ///
+ private static bool TryGetTokenId(
+ JwtSecurityToken jwt,
+ out Guid tokenId)
+ {
+ var jti = jwt.Claims
+ .FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Jti)
+ ?.Value;
+
+ return Guid.TryParse(jti, out tokenId);
+ }
+
+ ///
+ /// Verifies that a token-to-signing-key binding exists.
+ ///
+ /// The unique identifier of the developer token.
+ /// The signing key identifier from the JWT header.
+ ///
+ /// true when a matching binding exists; otherwise, false.
+ ///
+ private async Task IsBindingValid(
+ Guid tokenId,
+ string kid)
+ {
+ var binding = await keyBinding.GetBindingAsync(tokenId, kid);
+
+ if (binding != null) return true;
+ logger.LogWarning("[Validator] No binding found for TokenId={TokenId}, Kid={Kid}", tokenId, kid);
+ return false;
+
+ }
+
+ ///
+ /// Validates the JWT signature and lifetime and creates the corresponding principal.
+ ///
+ /// The JWT to validate.
+ /// The signing credentials associated with the token's signing key.
+ ///
+ /// A when validation succeeds;
+ /// otherwise, null.
+ ///
+ private DeveloperTokenPrincipal? ValidateSignatureAndBuildPrincipal(
+ string jwtToken,
+ SigningCredentials creds)
+ {
+ try
+ {
+ var principal = _handler.ValidateToken(
+ jwtToken,
+ new TokenValidationParameters
+ {
+ RequireExpirationTime = true,
+ ValidateLifetime = true,
+ ValidateIssuer = false,
+ ValidateAudience = false,
+ IssuerSigningKey = creds.Key
+ },
+ out _);
+
+ return new DeveloperTokenPrincipal(
+ GetClaimValue(principal, "sub"),
+ GetClaimValue(principal, "name"),
+ principal.Claims
+ .Where(c => c.Type == "scope")
+ .Select(c => c.Value)
+ );
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "[Validator] Signature/lifetime validation failed");
+ return null;
+ }
+ }
+
+ ///
+ /// Retrieves and converts a claim value from the specified principal.
+ ///
+ /// The expected claim value type.
+ /// The claims principal containing the claim.
+ /// The claim type to retrieve.
+ /// The converted claim value.
+ private static T GetClaimValue(
+ ClaimsPrincipal principal,
+ string type)
+ {
+ var value = principal.Claims.First(c => c.Type == type).Value;
+ return (T)Convert.ChangeType(value, typeof(T));
+ }
+}
\ No newline at end of file
diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs
similarity index 74%
rename from Application/Features/DeveloperTokens/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs
index 34501f1..cd432b2 100644
--- a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/CreateDeveloperTokenHandler.cs
@@ -1,12 +1,13 @@
-using Application.Features.DeveloperTokens.DTO;
-using Application.Features.DeveloperTokens.Interfaces;
-using Application.Features.DeveloperTokens.UseCase.Commands.Requests;
-using Application.Options;
-using Domain.Features.DeveloperTokens;
-using Domain.Features.DeveloperTokens.Repositories;
+using DevTokens.DeveloperTokens;
+using DevTokens.DTO;
+using DevTokens.Exceptions;
+using DevTokens.Interfaces;
+using DevTokens.Options;
+using DevTokens.Repositories;
+using DevTokens.UseCase.Commands.Requests;
using Microsoft.Extensions.Options;
-namespace Application.Features.DeveloperTokens.UseCase.Commands.Handlers;
+namespace DevTokens.UseCase.Commands.Handlers;
///
/// Handles the command.
diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs
similarity index 72%
rename from Application/Features/DeveloperTokens/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs
index 13faff2..e51fb9e 100644
--- a/Application/Features/DeveloperTokens/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Handlers/DeleteTokenCommandHandler.cs
@@ -1,7 +1,7 @@
-using Application.Features.DeveloperTokens.Interfaces;
-using Application.Features.DeveloperTokens.UseCase.Commands.Requests;
+using DevTokens.Interfaces;
+using DevTokens.UseCase.Commands.Requests;
-namespace Application.Features.DeveloperTokens.UseCase.Commands.Handlers;
+namespace DevTokens.UseCase.Commands.Handlers;
///
/// Handles the command.
diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs
similarity index 89%
rename from Application/Features/DeveloperTokens/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs
index 28111d1..43cbc71 100644
--- a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/CreateDeveloperTokenCommand.cs
@@ -1,4 +1,4 @@
-namespace Application.Features.DeveloperTokens.UseCase.Commands.Requests;
+namespace DevTokens.UseCase.Commands.Requests;
///
/// Command for creating a new developer token.
diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/DeleteTokenCommand.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/DeleteTokenCommand.cs
similarity index 83%
rename from Application/Features/DeveloperTokens/UseCase/Commands/Requests/DeleteTokenCommand.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/DeleteTokenCommand.cs
index 1b66d4c..4b7d378 100644
--- a/Application/Features/DeveloperTokens/UseCase/Commands/Requests/DeleteTokenCommand.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Requests/DeleteTokenCommand.cs
@@ -1,4 +1,4 @@
-namespace Application.Features.DeveloperTokens.UseCase.Commands.Requests;
+namespace DevTokens.UseCase.Commands.Requests;
///
/// Represents a command to delete a specific developer token.
diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs
similarity index 90%
rename from Application/Features/DeveloperTokens/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs
index 678763e..81ea0ba 100644
--- a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/CreateDeveloperTokenValidator.cs
@@ -1,7 +1,7 @@
-using Application.Features.DeveloperTokens.UseCase.Commands.Requests;
+using DevTokens.UseCase.Commands.Requests;
using FluentValidation;
-namespace Application.Features.DeveloperTokens.UseCase.Commands.Validations;
+namespace DevTokens.UseCase.Commands.Validations;
///
/// Validator for .
diff --git a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs
similarity index 77%
rename from Application/Features/DeveloperTokens/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs
index 51ac9d7..a320470 100644
--- a/Application/Features/DeveloperTokens/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Commands/Validations/DeleteTokenCommandValidator.cs
@@ -1,7 +1,7 @@
-using Application.Features.DeveloperTokens.UseCase.Commands.Requests;
+using DevTokens.UseCase.Commands.Requests;
using FluentValidation;
-namespace Application.Features.DeveloperTokens.UseCase.Commands.Validations;
+namespace DevTokens.UseCase.Commands.Validations;
///
/// Validator for .
diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs
similarity index 79%
rename from Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs
index 923157a..f4184c3 100644
--- a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokenByIdQueryHandler.cs
@@ -1,8 +1,8 @@
-using Application.Features.DeveloperTokens.DTO;
-using Application.Features.DeveloperTokens.Interfaces;
-using Application.Features.DeveloperTokens.UseCase.Queries.Requests;
+using DevTokens.DTO;
+using DevTokens.Interfaces;
+using DevTokens.UseCase.Queries.Requests;
-namespace Application.Features.DeveloperTokens.UseCase.Queries.Handlers;
+namespace DevTokens.UseCase.Queries.Handlers;
///
/// Handles the query.
diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs
similarity index 74%
rename from Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs
index 172dc08..e07cd9a 100644
--- a/Application/Features/DeveloperTokens/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Handlers/GetDeveloperTokensQueryHandler.cs
@@ -1,8 +1,8 @@
-using Application.Features.DeveloperTokens.DTO;
-using Application.Features.DeveloperTokens.Interfaces;
-using Application.Features.DeveloperTokens.UseCase.Queries.Requests;
+using DevTokens.DTO;
+using DevTokens.Interfaces;
+using DevTokens.UseCase.Queries.Requests;
-namespace Application.Features.DeveloperTokens.UseCase.Queries.Handlers;
+namespace DevTokens.UseCase.Queries.Handlers;
///
/// Handles the query.
diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs
similarity index 76%
rename from Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs
index 2566f3e..1e1b26d 100644
--- a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokenByIdQuery.cs
@@ -1,4 +1,4 @@
-namespace Application.Features.DeveloperTokens.UseCase.Queries.Requests;
+namespace DevTokens.UseCase.Queries.Requests;
///
/// Query for retrieving a specific developer token by its unique identifier.
diff --git a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs
similarity index 79%
rename from Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs
rename to src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs
index 59c8084..fca707c 100644
--- a/Application/Features/DeveloperTokens/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs
+++ b/src/Plugins/Solutions/DevTokens/src/UseCase/Queries/Requests/GetDeveloperTokensQuery.cs
@@ -1,4 +1,4 @@
-namespace Application.Features.DeveloperTokens.UseCase.Queries.Requests;
+namespace DevTokens.UseCase.Queries.Requests;
///
/// Query for retrieving all developer tokens associated with a specific developer.
diff --git a/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenLifetime.cs b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenLifetime.cs
new file mode 100644
index 0000000..3e588f7
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenLifetime.cs
@@ -0,0 +1,96 @@
+namespace DevTokens.ValueObject;
+
+///
+/// Represents the lifetime of developer token, including its creation time
+/// and optional expiration time.
+///
+///
+///
+/// A token lifetime may be finite or unlimited. When
+/// is null, the token does not have an expiration time.
+///
+///
+/// - specifies when the token was created.
+/// - specifies when the token expires, or null when the token never expires.
+/// - returns the lifetime duration in whole days when an expiration time is configured.
+/// - indicates whether the token has passed its expiration time.
+/// - returns the time remaining until expiration, or null for tokens without an expiration time.
+///
+///
+public sealed record TokenLifetime
+{
+ ///
+ /// Gets the date and time when the token was created.
+ ///
+ public DateTimeOffset CreatedAt { get; init; }
+
+ ///
+ /// Gets the date and time when the token expires.
+ /// A null value indicates that the token never expires.
+ ///
+ public DateTimeOffset? ExpiresAt { get; init; }
+
+ ///
+ /// Initializes new instance of the record.
+ ///
+ /// The date and time when the token was created.
+ /// The optional expiration date and time of the token.
+ /// Thrown when is earlier than
+ public TokenLifetime(
+ DateTimeOffset createdAt,
+ DateTimeOffset? expiresAt = null)
+ {
+ if (expiresAt.HasValue && expiresAt.Value < createdAt)
+ {
+ throw new ArgumentException(
+ "Expiration date cannot be earlier than creation date.",
+ nameof(expiresAt));
+ }
+
+ CreatedAt = createdAt;
+ ExpiresAt = expiresAt;
+ }
+
+ ///
+ /// Gets the configured lifetime in days.
+ ///
+ ///
+ /// The number of days between creation and expiration, or null
+ /// when the token does not expire.
+ ///
+ public int? Days => ExpiresAt.HasValue
+ ? (int?)(ExpiresAt.Value - CreatedAt).TotalDays
+ : null;
+
+ ///
+ /// Gets value indicating whether the token has expired.
+ ///
+ ///
+ /// true when the token has an expiration time that has passed;
+ /// otherwise, false.
+ ///
+ public bool IsExpired =>
+ ExpiresAt.HasValue &&
+ DateTimeOffset.UtcNow > ExpiresAt.Value;
+
+ ///
+ /// Gets the remaining time until the token expires.
+ ///
+ /// The remaining duration until expiration, or null when the token does not expire.
+ public TimeSpan? Remaining =>
+ ExpiresAt.HasValue
+ ? ExpiresAt.Value - DateTimeOffset.UtcNow
+ : null;
+
+ ///
+ /// Returns string representation of the token lifetime.
+ ///
+ ///
+ /// Formatted range containing the creation and expiration times,
+ /// or never when no expiration time is configured.
+ ///
+ public override string ToString() =>
+ ExpiresAt.HasValue
+ ? $"{CreatedAt:u} - {ExpiresAt:u}"
+ : $"{CreatedAt:u} - never";
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenName.cs b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenName.cs
new file mode 100644
index 0000000..8a993ad
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenName.cs
@@ -0,0 +1,34 @@
+namespace DevTokens.ValueObject;
+
+///
+/// Represents the name assigned to a developer token.
+///
+///
+///
+/// - The value cannot be null, empty, or consist only of whitespace.
+/// - The value is limited to a maximum of 100 characters.
+/// - Leading and trailing whitespace is removed during object creation.
+/// - Supports implicit conversion from to .
+/// - Supports implicit conversion from to .
+///
+///
+public readonly record struct TokenName(string Value)
+{
+ ///
+ /// Returns the token name as a string.
+ ///
+ /// The underlying token name value.
+ public override string ToString() => Value;
+
+ ///
+ /// Implicitly converts to .
+ ///
+ /// The token name to convert.
+ public static implicit operator string(TokenName t) => t.Value;
+
+ ///
+ /// Implicitly converts to .
+ ///
+ /// The string value to convert.
+ public static implicit operator TokenName(string s) => new(s);
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenScope.cs b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenScope.cs
new file mode 100644
index 0000000..dfd157b
--- /dev/null
+++ b/src/Plugins/Solutions/DevTokens/src/ValueObject/TokenScope.cs
@@ -0,0 +1,60 @@
+using System.Text.Json.Serialization;
+
+namespace DevTokens.ValueObject;
+
+///
+/// Represents a normalized scope assigned to the developer token.
+///
+///
+///
+/// - The scope name cannot be null, empty, or consist only of whitespace.
+/// - Leading and trailing whitespace is removed during object creation.
+/// - The scope name is normalized to lowercase using the invariant culture.
+/// - Supports implicit conversion from to .
+/// - Supports implicit conversion from to .
+///
+///
+public readonly record struct TokenScope
+{
+ ///
+ /// Gets the normalized scope name.
+ ///
+ public string Value { get; }
+
+ ///
+ /// Initializes new instance of the struct.
+ ///
+ /// The scope name to validate and normalize.
+ ///
+ /// Thrown when is null, empty,
+ /// or consists only of whitespace.
+ ///
+ [JsonConstructor]
+ public TokenScope(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentException(
+ "Scope name cannot be null or empty.",
+ nameof(value));
+
+ Value = value.Trim().ToLowerInvariant();
+ }
+
+ ///
+ /// Implicitly converts to .
+ ///
+ /// The scope name to convert.
+ public static implicit operator TokenScope(string s) => new(s);
+
+ ///
+ /// Implicitly converts to .
+ ///
+ /// The scope to convert.
+ public static implicit operator string(TokenScope s) => s.Value;
+
+ ///
+ /// Returns the normalized scope name.
+ ///
+ /// The underlying scope value.
+ public override string ToString() => Value;
+}
\ No newline at end of file