From bb34d2632ca70f838e2b5b92de50c288cad6f2ae Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:01:21 +0200 Subject: [PATCH 1/7] docs(adr): add signing key management ADRs --- .../001-centralize-signing-key-management.md | 82 +++++++++++++++++++ Docs/ADR/002-encrypt-keystore-at-rest.md | 55 +++++++++++++ ...ing-key-lifecycle-immutable-transitions.md | 54 ++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 Docs/ADR/001-centralize-signing-key-management.md create mode 100644 Docs/ADR/002-encrypt-keystore-at-rest.md create mode 100644 Docs/ADR/003-signing-key-lifecycle-immutable-transitions.md diff --git a/Docs/ADR/001-centralize-signing-key-management.md b/Docs/ADR/001-centralize-signing-key-management.md new file mode 100644 index 0000000..b016aa9 --- /dev/null +++ b/Docs/ADR/001-centralize-signing-key-management.md @@ -0,0 +1,82 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous]() | [Next](./002-encrypt-keystore-at-rest.md) + +# [ADR-001] Centralize Signing Key Management Through A Core Key Store Abstraction + +*2026-08* | Status: accepted + +**Tag:** #adr_001 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.KeyManagement + +## Context + +The AuthKit core owns the cryptographic material used to issue and verify JWTs. It exposes a small set of primitives - `IKeyGenerator`, `IKeyEncryptor`, `IJwtKeyStore`, and `IKeyStoreRepository` - that together cover key creation, at rest encryption, and persistence. The host layer (REST, gRPC, plugins) must be able to mint, rotate, and publish signing keys without taking on knowledge of how those keys are generated, encrypted, or stored. + +## Problem + +The host should not depend on concrete key storage formats, encryption algorithms, or generation parameters. Without central key management abstraction owned by the core, that knowledge leaks into the host and into individual endpoints: each consumer reinvents how keys are produced, how they are protected on disk, and how the active signing key is resolved. Renaming key, changing the encryption scheme, or swapping the storage backend then becomes cross cutting change with no single contract to anchor it. + +## Decision + +Signing key management is centralized behind core abstraction that forms a contract between key generation, encryption, and persistence on one side, and the host on the other. A signing key becomes a stable core entity (`SigningKey`) resolved through the key store, rather than a raw file or repository record known only to single endpoint. + +### IKeyGenerator + +**Responsibilities:** + +- Produce new asymmetric key pairs for signing (`GenerateAsync`). +- Expose the algorithm and parameters used so the rest of the core can reason about key strength. +- Remain agnostic of storage and encryption concerns. + +### IKeyEncryptor + +**Responsibilities:** + +- Encrypt raw key material for at rest protection (`EncryptAsync` / `DecryptAsync`). +- Keep the encryption algorithm (eg. AES) isolated from the store and generator. +- Never expose plaintext material outside the core boundary. + +### IJwtKeyStore + +**Responsibilities:** + +- Resolve the active signing key and the set of published public keys (`GetActiveKeyAsync`, `GetPublicJwksAsync`). +- Coordinate the generator and encryptor so that persisted keys are always encrypted. +- Present keys to the host as `SigningKey` entities and public JWKs, hiding storage details. + +### IKeyStoreRepository + +**Responsibilities:** + +- Persist and load the encrypted keystore record (`KeystoreOnDisk`, `KeyEntry`, `KeyMetadata`). +- Remain pure persistence boundary with no cryptographic or domain logic. + +### Design Rationale + +- A single core contract keeps cryptographic decisions testable, auditable, and swappable without touching the host. +- Separating generation, encryption, and storage lets each concern evolve independently (algorithm upgrade, storage backend change, rotation policy) behind stable interfaces. +- Exposing keys as domain entities and public JWKs prevents the host from coupling to on disk formats and reduces the risk of key handling mistakes. + +## Rejected + +- Letting the host generate, encrypt, and persist keys directly with ad hoc calls. +- Coupling signing key and encryption knowledge into concrete endpoint code. +- A single monolithic key service that merges generation, encryption, and storage in one class. +- Storing keys in plaintext or relying on host managed secrets rather than the core encryptor. + +## Consequences + +The core becomes the single, predictable owner of signing key lifecycle and it is easier to control key completeness, rotation, and algorithm consistency across the host. The cost is maintaining the core abstraction as a contract and updating it whenever new key management capability is introduced. + +## Related + +- [ADR-002](./002-encrypt-keystore-at-rest.md) - encryption of keystore bytes at rest +- [ADR-003](./003-signing-key-lifecycle-immutable-transitions.md) - signing key lifecycle transitions +- [ADR-005](./005-public-keys-via-jwks.md) - public key discovery via JWKS +- [ADR-006](./006-kid-as-generated-guid.md) - key identifier strategy +- [ADR-007](./007-default-signing-algorithm-rsa-4096.md) - default signing algorithm +- [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) - keystore persistence + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous]() | [Next](./002-encrypt-keystore-at-rest.md) diff --git a/Docs/ADR/002-encrypt-keystore-at-rest.md b/Docs/ADR/002-encrypt-keystore-at-rest.md new file mode 100644 index 0000000..f43e047 --- /dev/null +++ b/Docs/ADR/002-encrypt-keystore-at-rest.md @@ -0,0 +1,55 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./001-centralize-signing-key-management.md) | [Next](./003-signing-key-lifecycle-immutable-transitions.md) + +# [ADR-002] Encrypt Persisted Keystore Material At Rest Through A Pluggable Encryptor + +*2026-08* | Status: accepted + +**Tag:** #adr_002 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.KeyManagement + +## Context + +The key store persists sensitive RSA private key material so that signing keys survive restarts. The repository boundary (`IKeyStoreRepository`) is intentionally pure persistence sink - it stores and returns opaque bytes without understanding their content. + +## Problem + +Private key material must never be written to disk in plaintext. At the same time, the persistence layer must remain unaware of cryptography so it can be swapped (file, blob, database) without touching security logic. The system also needs a single, replaceable place to change the encryption algorithm if the threat model changes. + +## Decision + +All keystore bytes are encrypted before persistence and decrypted after load through the `IKeyEncryptor` boundary. The default implementation (`AesKeyEncryptor`) uses AES-256-CBC with 256-bit master key supplied at construction, generates cryptographically random IV per encryption call, and prefixes the IV to the ciphertext so it can be recovered on decryption. + +### IKeyEncryptor + +**Responsibilities:** + +- Encrypt UTF-8 plaintext into an opaque byte blob (`Encrypt`). +- Reverse the operation and return plaintext (`Decrypt`). +- Remain stateless and independent of the persistence repository. + +### Design Rationale + +- Keeping encryption in dedicated, injectable boundary lets the algorithm be changed (e.g. to AES-GCM) without modifying the store or repository. +- A per call random IV avoids key/IV reuse and keeps the on disk format non deterministic. +- The repository stays dumb byte store, so storage backends are interchangeable. + +## Rejected + +- Persisting keys in plaintext or relying on filesystem permissions alone. +- Letting `IKeyStoreRepository` perform encryption or decryption. +- A fixed, embedded key compiled into the assembly. +- Reusing single static IV across all encryptions. + +## Consequences + +Key material is confidential at rest and the encryption algorithm is isolated behind a contract. The documentation explicitly notes that AES-CBC provides confidentiality without authenticated integrity, so the encrypted blob must be protected from tampering by another mechanism or migrated to an authenticated mode. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - key store abstraction owning the contract +- [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) - where the encrypted bytes are persisted + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./001-centralize-signing-key-management.md) | [Next](./003-signing-key-lifecycle-immutable-transitions.md) diff --git a/Docs/ADR/003-signing-key-lifecycle-immutable-transitions.md b/Docs/ADR/003-signing-key-lifecycle-immutable-transitions.md new file mode 100644 index 0000000..a6cee5f --- /dev/null +++ b/Docs/ADR/003-signing-key-lifecycle-immutable-transitions.md @@ -0,0 +1,54 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./002-encrypt-keystore-at-rest.md) | [Next](./004-token-key-bindings-domain.md) + +# [ADR-003] Model Signing Key Lifecycle As Immutable State Transitions + +*2026-08* | Status: accepted + +**Tag:** #adr_003 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.KeyManagement.Entity + +## Context + +A signing key moves through a lifecycle: it is generated, becomes valid for signing, may be rotated, expires, and can be revoked. Many parts of the system need to ask a single, reliable question — "is this key allowed to sign right now?" — without reimplementing lifecycle rules. + +## Problem + +If lifecycle state lives only in storage or is mutated in place by services, the rules for activation, expiration, and revocation get duplicated and drift. Mutable entities also make concurrent key operations and auditing harder to reason about. + +## Decision + +The signing key is modeled as an immutable `SigningKey` record. Lifecycle changes - `Activate`, `Revoke`, `Expire` - retur new instance via `with` expressions rather than mutating state, and a single `IsValidForSigning(now)` predicate evaluates activation, revocation, and expiration against supplied timestamp. + +### SigningKey + +**Responsibilities:** + +- Carry the key identifier, public key (PEM), encrypted private material, algorithm, and lifecycle timestamps. +- Expose `Activate`, `Revoke`, and `Expire` as pure transitions that produce a new record. +- Provide `IsValidForSigning(DateTime now)` as the authoritative validity check. + +### Design Rationale + +- Immutability makes transitions easy to test and free of hidden side effects. +- One predicate for validity prevents lifecycle logic from being copied across the store, host, and bindings. +- Timestamp based evaluation keeps the rule deterministic and independent of wall clock assumptions inside the entity. + +## Rejected + +- A mutable entity with public setters changed directly by services. +- Storing lifecycle state only in the database and recomputing rules in each consumer. +- Embedding activation/expiration/revocation logic inside `JwtKeyStore` instead of the entity. + +## Consequences + +Lifecycle behavior is centralized and consistent, and transitions are auditable as value copies. The cost is richer record shape and the discipline of always using the returned instance rather than the original. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - key store that consumes the lifecycle +- [ADR-005](./005-public-keys-via-jwks.md) - JWKS excludes revoked keys using this state + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./002-encrypt-keystore-at-rest.md) | [Next](./004-token-key-bindings-domain.md) From e99854b35a1a8639aca52dcf85685a8bc857b6bd Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:25:28 +0200 Subject: [PATCH 2/7] docs(adr): document AuthKit.Core domain decisions --- Docs/ADR/004-token-key-bindings-domain.md | 67 +++++++++++++++++++ Docs/ADR/005-public-keys-via-jwks.md | 54 +++++++++++++++ Docs/ADR/006-kid-as-generated-guid.md | 47 +++++++++++++ .../007-default-signing-algorithm-rsa-4096.md | 46 +++++++++++++ Docs/ADR/008-standardized-error-response.md | 59 ++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 Docs/ADR/004-token-key-bindings-domain.md create mode 100644 Docs/ADR/005-public-keys-via-jwks.md create mode 100644 Docs/ADR/006-kid-as-generated-guid.md create mode 100644 Docs/ADR/007-default-signing-algorithm-rsa-4096.md create mode 100644 Docs/ADR/008-standardized-error-response.md diff --git a/Docs/ADR/004-token-key-bindings-domain.md b/Docs/ADR/004-token-key-bindings-domain.md new file mode 100644 index 0000000..2043353 --- /dev/null +++ b/Docs/ADR/004-token-key-bindings-domain.md @@ -0,0 +1,67 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./003-signing-key-lifecycle-immutable-transitions.md) | [Next](./005-public-keys-via-jwks.md) + +# [ADR-004] Treat Developer Token To Signing Key Bindings As Core Domain + +*2026-08* | Status: accepted + +**Tag:** #adr_004 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.TokenKeyBindings + +## Context + +Developer tokens must be associated with the specific RSA signing key (and its public key) used to sign their JWTs, so that verification and rotation can be tracked per token. This association is domain concept, not an implementation detail of single endpoint. + +## Problem + +Without first class binding concept, the mapping between tokens and signing keys is scattered across hosts and key material, making rotation, public key updates, and revocation hard to trace and inconsistent across API surfaces. + +## Decision + +Token to key bindings are core domain: the `TokenKeyBinding` record captures `TokenId`, `SigningKeyId`, the public key, `BoundAt` timestamp, and `Revoked` flag. `IKeyBindingService` applies domain behavior (`CreateBindingAsync`, `RebindAsync`, `UpdatePublicKeyAsync`, `RevokeAsync`, `ListBindingsAsync`, `GetBindingAsync`), and `IKeyBindingRepository` is pure persistence boundary. + +### TokenKeyBinding + +**Responsibilities:** + +- Represent the immutable by copy association between developer token and signing key. +- Expose `Rebind`, `UpdatePublicKey`, and `Revoke` as transitions returning a new record with refreshed `BoundAt`. + +### IKeyBindingService + +**Responsibilities:** + +- Orchestrate binding operations through the repository. +- Invoke the entity's transitions rather than mutating binding state directly. + +### IKeyBindingRepository + +**Responsibilities:** + +- Persist, load, update, and list bindings. +- Stay free of cryptographic and domain behavior. + +### Design Rationale + +- A dedicated domain keeps per token key provenance explicit and supports rotation without breaking verification. +- Separating service (behavior) from repository (persistence) mirrors the key management design and stays testable. + +## Rejected + +- Encoding the token -> key mapping implicitly inside the key store. +- Letting hosts manage bindings through ad hoc database access. +- Mutating binding fields directly from the service instead of using entity transitions. + +## Consequences + +Token key provenance, rotation, and revocation are consistent and observable. The cost is maintaining second core domain alongside key management and keeping the repository boundary clean. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - signing keys the bindings reference +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugins consume bindings through this domain +- [ADR-012](./012-token-key-bindings-persisted-in-marten.md) - persistence of bindings + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./003-signing-key-lifecycle-immutable-transitions.md) | [Next](./005-public-keys-via-jwks.md) diff --git a/Docs/ADR/005-public-keys-via-jwks.md b/Docs/ADR/005-public-keys-via-jwks.md new file mode 100644 index 0000000..3e20aeb --- /dev/null +++ b/Docs/ADR/005-public-keys-via-jwks.md @@ -0,0 +1,54 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./004-token-key-bindings-domain.md) | [Next](./006-kid-as-generated-guid.md) + +# [ADR-005] Publish Public Keys Through JWKS Exposing Only Non Revoked Keys + +*2026-08* | Status: accepted + +**Tag:** #adr_005 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.KeyManagement + +## Context + +Relying parties verify JWT signatures by fetching the issuer's public keys. The key store already holds each key's RSA modulus and exponent plus its lifecycle metadata, and it keeps an in-memory view of all loaded keys. + +## Problem + +Consumers need standard, cache friendly way to discover valid public keys. If revoked or expired keys are published, or if the response is recomputed on every request without invalidation, verifiers may accept bad signatures or the endpoint wastes CPU reconstructing the key set. + +## Decision + +The key store exposes public keys via `GetPublicJwks()` as `PublicJwkDto` instances in JWKS compatible shape (`kty=RSA`, `use=sig`, `kid`, `alg`, `n`, `e`). Only keys whose `Revoked` metadata is false are included, and the result is cached, rebuilt only when the key set changes (rotation or revocation). + +### PublicJwkDto + +**Responsibilities:** + +- Carry the JWKS fields needed by external verifiers. +- Remain read only projection of the in-memory key entry. + +### Design Rationale + +- JWKS is an industry standard discovery format, so off the shelf verifiers integrate without custom code. +- Excluding revoked keys prevents accepting signatures from retired keys. +- Caching with explicit invalidation balances performance against freshness after rotation. + +## Rejected + +- Publishing revoked or expired keys in the JWKS response. +- Regenerating the key set on every request without cache. +- Inventing custom public key discovery format instead of JWKS. + +## Consequences + +External verifiers get correct, standard key set and the endpoint stays efficient. The cost is explicitly invalidating the cache on every lifecycle change and keeping the projection in sync with key metadata. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - key store exposing the JWKS +- [ADR-003](./003-signing-key-lifecycle-immutable-transitions.md) - lifecycle drives revocation filtering +- [ADR-006](./006-kid-as-generated-guid.md) - `kid` values published in the key set + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./004-token-key-bindings-domain.md) | [Next](./006-kid-as-generated-guid.md) diff --git a/Docs/ADR/006-kid-as-generated-guid.md b/Docs/ADR/006-kid-as-generated-guid.md new file mode 100644 index 0000000..6be99b5 --- /dev/null +++ b/Docs/ADR/006-kid-as-generated-guid.md @@ -0,0 +1,47 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./005-public-keys-via-jwks.md) | [Next](./007-default-signing-algorithm-rsa-4096.md) + +# [ADR-006] Derive The JWT Key Identifier As Generated GUID + +*2026-08* | Status: accepted + +**Tag:** #adr_006 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.KeyManagement + +## Context + +Every signing key needs stable identifier that appears as the JWT `kid` header, indexes the in-memory key dictionary, and links persistence records and public JWK entries. The generator creates keys with no external identifier assigned. + +## Problem + +If the identifier is meaningful (eg. encodes the public key or sequence), it can break when a key is rotated or its material regenerated, and it can leak internal structure. The system needs one identifier that is stable for the life of the key across all representations. + +## Decision + +The key generator assigns each new key `kid` produced by `Guid.NewGuid().ToString("N")`. That same value is used as the JWT `kid`, the `ConcurrentDictionary` key in `JwtKeyStore`, the `KeyMetadata.Kid`, and the JWKS `kid` field, so all representations are anchored to one identifier. + +### Design Rationale + +- A random GUID is unique, opaque, and stable for the key's lifetime regardless of rotation or re-export. +- Reusing one identifier everywhere removes translation logic between persistence, memory, JWTs, and JWKS. +- No internal structure is exposed to relying parties. + +## Rejected + +- Sequential or meaningful identifiers that reveal ordering or internals. +- Deriving the `kid` from a hash of the public key, which complicates rotation and re-exports. +- Letting callers or the host assign the `kid` externally. + +## Consequences + +Key identity is consistent and collision free across the system. The cost is that the `kid` carries no human-readable meaning, so debugging relies on metadata rather than the identifier itself. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - key store indexing by `kid` +- [ADR-005](./005-public-keys-via-jwks.md) - `kid` in the published key set +- [ADR-007](./007-default-signing-algorithm-rsa-4096.md) - algorithm paired with the identifier + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./005-public-keys-via-jwks.md) | [Next](./007-default-signing-algorithm-rsa-4096.md) diff --git a/Docs/ADR/007-default-signing-algorithm-rsa-4096.md b/Docs/ADR/007-default-signing-algorithm-rsa-4096.md new file mode 100644 index 0000000..a7da091 --- /dev/null +++ b/Docs/ADR/007-default-signing-algorithm-rsa-4096.md @@ -0,0 +1,46 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./006-kid-as-generated-guid.md) | [Next](./008-standardized-error-response.md) + +# [ADR-007] Default Signing Algorithm Is RSA-4096 With RS256 + +*2026-08* | Status: accepted + +**Tag:** #adr_007 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core.KeyManagement + +## Context + +The key generator and key store must agree on the algorithm and key strength used to produce and verify JWTs. `IKeyGenerator.Generate` takes configurable `rsaBits`, and `JwtKeyStore` builds `SigningCredentials` with fixed algorithm constant. + +## Problem + +Without declared default, callers may pick weak key sizes or mismatched algorithms, and the store and generator could drift apart, producing keys the store cannot use or verify consistently. + +## Decision + +The default signing key is RSA with 4096-bit modulus, and signatures use `SecurityAlgorithms.RsaSha256` (RS256). The default size is expressed once as the `rsaBits = 4096` parameter on `IKeyGenerator.Generate` and on `JwtKeyStore.RotateAsync`, while the algorithm is fixed at credential creation time; the size remains overridable by the caller for rotation. + +### Design Rationale + +- RSA-4096 gives conservative security margin for long lived signing keys. +- RS256 is widely supported by JWT verifiers and pairs naturally with the RSA keys and JWKS published by the store. +- Centralizing the default keeps generator and store aligned and makes future algorithm change single point edit. + +## Rejected + +- Defaulting to RSA-2048 or smaller for "performance". +- Making ECDSA the default algorithm. +- Hardcoding the algorithm inside the store while leaving the generator free to choose different one. + +## Consequences + +New keys are consistently strong and verifiable across the ecosystem. The cost is0 larger key size (more CPU per signature) and the need to revisit the default if the threat model or standard support changes. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - key store building credentials +- [ADR-006](./006-kid-as-generated-guid.md) - identifier generated alongside the key + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./006-kid-as-generated-guid.md) | [Next](./008-standardized-error-response.md) diff --git a/Docs/ADR/008-standardized-error-response.md b/Docs/ADR/008-standardized-error-response.md new file mode 100644 index 0000000..15308f6 --- /dev/null +++ b/Docs/ADR/008-standardized-error-response.md @@ -0,0 +1,59 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./007-default-signing-algorithm-rsa-4096.md) | [Next](./009-dynamic-plugin-discovery.md) + +# [ADR-008] Standardize API Errors Through Core Error Response Contract + +*2026-08* | Status: accepted + +**Tag:** #adr_008 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Core + +## Context + +Hosts (REST, gRPC, plugins) report failures to API clients and need consistent shape so consumers can parse errors programmatically. The core already define `DomainException` base for rule violations and an `ErrorMetadataOptions` for documentation links. + +## Problem + +Without shared error contract owned by the core, each host invents its own JSON schema, field names, and timestamp handling, fragmenting client error handling and breaking interoperability between API surfaces. + +## Decision + +The core owns standardized `ErrorResponse` DTO with `error` and `error_description` JSON fields (matching OAuth2 error naming) and UTC `timestamp`, `DomainException` base that hosts map onto it. Error documentation references are centralized in `ErrorMetadataOptions.DocsBaseUrl`. + +### ErrorResponse + +**Responsibilities:** + +- Present stable, client facing error shape (`error`, `error_description`, `timestamp`). +- Remain serialization friendly and host agnostic. + +### DomainException + +**Responsibilities:** + +- Act as the common base for domain rule violations. +- Give hosts one type to catch and translate into `ErrorResponse`. + +### Design Rationale + +- A single contract keeps client error parsing uniform across all hosts. +- Field names maximize compatibility with standard clients. +- The UTC timestamp aids correlation and auditing without host specific logic. + +## Rejected + +- Letting each host define its own error JSON shape. +- Returning raw exception text or stack traces to clients. +- Omitting timestamp or documentation link from the contract. + +## Consequences + +Clients get predictable error format and hosts share one translation path from domain failures. The cost is keeping `ErrorResponse` and `DomainException` in sync as new error cases appear. + +## Related + +- [ADR-014](./014-error-responses-via-middleware.md) - host renders this contract as ProblemDetails + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./007-default-signing-algorithm-rsa-4096.md) | [Next](./009-dynamic-plugin-discovery.md) From 810b5ac2c302928afe86b7c99ed2fe4d6f256f9d Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:27:39 +0200 Subject: [PATCH 3/7] feat(key management): persist key bindings with Marten --- .../ApplicationInitialization.cs | 2 +- .../Configuration/AuthKitConfiguration.cs | 2 +- .../InMemoryKeyBindingRepository.cs | 141 ------------------ .../Repositories/KeyBindingRepository.cs | 116 ++++++++++++++ 4 files changed, 118 insertions(+), 143 deletions(-) delete mode 100644 src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs create mode 100644 src/Host/TokenKeyBindings/Repositories/KeyBindingRepository.cs diff --git a/src/Host/Configuration/ApplicationInitialization.cs b/src/Host/Configuration/ApplicationInitialization.cs index ac645f0..7fd793f 100644 --- a/src/Host/Configuration/ApplicationInitialization.cs +++ b/src/Host/Configuration/ApplicationInitialization.cs @@ -70,7 +70,7 @@ public static IServiceCollection ConfigureApp( opts.ExcludedTypes.Add(typeof(RsaKeyGenerator)); opts.ExcludedTypes.Add(typeof(JwtKeyStore)); opts.ExcludedTypes.Add(typeof(KeyStoreRepository)); - opts.ExcludedTypes.Add(typeof(InMemoryKeyBindingRepository)); + opts.ExcludedTypes.Add(typeof(KeyBindingRepository)); }, discoveryLogger) .AddFluentValidation(); diff --git a/src/Host/Configuration/AuthKitConfiguration.cs b/src/Host/Configuration/AuthKitConfiguration.cs index dcac060..c31f5b5 100644 --- a/src/Host/Configuration/AuthKitConfiguration.cs +++ b/src/Host/Configuration/AuthKitConfiguration.cs @@ -69,7 +69,7 @@ public static void AddAuthKitCore( services.AddSingleton(); services.AddHostedService(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); } } diff --git a/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs b/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs deleted file mode 100644 index c174bea..0000000 --- a/src/Host/TokenKeyBindings/Repositories/InMemoryKeyBindingRepository.cs +++ /dev/null @@ -1,141 +0,0 @@ -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/src/Host/TokenKeyBindings/Repositories/KeyBindingRepository.cs b/src/Host/TokenKeyBindings/Repositories/KeyBindingRepository.cs new file mode 100644 index 0000000..823b61b --- /dev/null +++ b/src/Host/TokenKeyBindings/Repositories/KeyBindingRepository.cs @@ -0,0 +1,116 @@ +using Core.TokenKeyBindings.Interfaces; +using Core.TokenKeyBindings.Services; +using Marten; + +namespace Host.TokenKeyBindings.Repositories; + +/// +/// Provides Marten based repository for persisting +/// entities. +/// +/// +/// +/// Each binding is stored as a single Marten document identified by a composite +/// document id built from the developer token id and the signing key id. Only the +/// serialized binding is persisted; the repository performs no domain logic. +/// +/// +public sealed class KeyBindingRepository(IDocumentStore store) : IKeyBindingRepository +{ + /// + /// Builds the fixed Marten document identifier fo binding. + /// + private static string DocumentId(Guid tokenId, string signingKeyId) => + $"{tokenId:N}:{signingKeyId}"; + + /// + /// adds new key binding to Marten. + /// + /// The key binding to persist. + /// The persisted . + public async Task AddAsync(TokenKeyBinding binding) + { + await using var session = store.LightweightSession(); + + session.Store(new KeyBindingDocument + { + Id = DocumentId(binding.TokenId, binding.SigningKeyId), + Binding = binding + }); + + await session.SaveChangesAsync(); + return binding; + } + + /// + /// retrieves key binding by token id and signing key id. + /// + /// The unique identifier of the developer token. + /// The identifier of the signing key. + /// + /// The matching , or null when none exists. + /// + public async Task GetAsync(Guid tokenId, string signingKeyId) + { + await using var session = store.LightweightSession(); + var document = await session.LoadAsync( + DocumentId(tokenId, signingKeyId)); + + return document?.Binding; + } + + /// + /// updates an existing key binding in Marten. + /// + /// The updated key binding. + public async Task UpdateAsync(TokenKeyBinding binding) + { + await using var session = store.LightweightSession(); + + session.Store(new KeyBindingDocument + { + Id = DocumentId(binding.TokenId, binding.SigningKeyId), + Binding = binding + }); + + await session.SaveChangesAsync(); + } + + /// + /// lists all key bindings associated with a developer token. + /// + /// The unique identifier of the developer token. + /// All entities for the token. + public async Task> ListByTokenAsync(Guid tokenId) + { + await using var session = store.LightweightSession(); + + var documents = await session.Query() + .Where(document => document.Binding.TokenId == tokenId) + .ToListAsync(); + + return documents.Select(document => document.Binding); + } + + /// + /// Represents the persisted Marten document containing a single + /// . + /// + /// + /// The document intentionally contains only the binding payload. The + /// repository does not perform domain transitions; those live on the + /// entity itself. + /// + public sealed class KeyBindingDocument + { + /// + /// Gets or sets the Marten document identifier. + /// + public string Id { get; set; } = null!; + + /// + /// Gets or sets the persisted binding. + /// + public TokenKeyBinding Binding { get; set; } = null!; + } +} From 249a7e89f75f563386163ec6ba74cfe1413935de Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:33:25 +0200 Subject: [PATCH 4/7] docs(adr): document dynamic plugin discovery and loading --- Docs/ADR/009-dynamic-plugin-discovery.md | 65 +++++++++++++++++++ Docs/ADR/010-plugin-loading-from-directory.md | 61 +++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 Docs/ADR/009-dynamic-plugin-discovery.md create mode 100644 Docs/ADR/010-plugin-loading-from-directory.md diff --git a/Docs/ADR/009-dynamic-plugin-discovery.md b/Docs/ADR/009-dynamic-plugin-discovery.md new file mode 100644 index 0000000..481eed3 --- /dev/null +++ b/Docs/ADR/009-dynamic-plugin-discovery.md @@ -0,0 +1,65 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./008-standardized-error-response.md) | [Next](./010-plugin-loading-from-directory.md) + +# [ADR-009] Discover And Load Plugins Dynamically Through The IAuthKitPlugin Contract + +*2026-08* | Status: accepted + +**Tag:** #adr_009 + +**Date:** 2026-08-26 + +**Scope:** AuthKit.Plugins.Abstractions + +## Context + +AuthKit.Server is composed of host plus optional solution packages (for example, the `DevTokens` developer token solution). The host must be able to grow its feature set new services, middleware, health checks, and authentication schemes - without recompiling or directly referencing every solution at build time. + +## Problem + +If solutions are wired into the host through static project references and hand written registration, the host couples to each feature and every new capability becomes host change. The host also needs uniform way to let an extension contribute to the DI container, request pipeline, health surface, and OpenAPI metadata, while keeping each extension isolated behind stable abstractions. + +## Decision + +Plugins implement the `IAuthKitPlugin` contract and are discovered and loaded dynamically by the host at startup. plugin does **not** need to be directly referenced by the host project. An extension contributes to the running server through well defined, optional members of the contract. + +### IAuthKitPlugin + +**Responsibilities:** + +- Identify the plugin (`Name`, `Version`, optional `Description`) for diagnostics and metadata. +- Register the plugin's services in the host DI container via `ConfigureServices` (the plugin must not create its own container). +- Optionally contribute an ASP.NET Core middleware type (`MiddlewareType`) inserted by the host at the plugin pipeline slot. +- Optionally expose health signal via `CheckHealthAsync`, resolved from the host's root service provider. +- Optionally contribute transport agnostic OpenAPI security schemes via `GetSecuritySchemes`. + +### AuthKitSecuritySchemeDescriptor + +**Responsibilities:** + +- Describe an authentication mechanism (API key, HTTP, OAuth2, OpenID Connect) at the metadata level. +- Remain transport agnostic so the same scheme can back HTTP headers and gRPC metadata. + +### Design Rationale + +- Dynamic discovery keeps the host decoupled from concrete solutions and lets features ship as drop-in packages. +- A single contract with sensible default implementations for optional members keeps plugins small and the host's integration code uniform. +- Isolation behind `AuthKit.Plugins.Abstractions` means plugins depend only on the contract, not on host internals signing keys and token to key bindings stay owned by the core and are consumed through DI rather than reimplemented. + +## Rejected + +- Static project references and compile time registration for each solution. +- Letting each plugin create and manage its own dependency injection container. +- Hardcoding plugin middleware or health checks in the host instead of discovering them from the contract. +- Embedding transport-specific authentication details directly in plugin code rather than through transport agnostic descriptor. + +## Consequences + +The host stays stable while features are added as independently loadable plugins, and each plugin integrates through one predictable surface. The cost is discovery/loading mechanism at startup and the discipline of keeping plugin behavior within the contract's boundaries plugins must rely on injected core services (such as signing keys) rather than reaching into host internals. + +## Related + +- [ADR-010](./010-plugin-loading-from-directory.md) - host-side plugin discovery and loading +- [ADR-004](./004-token-key-bindings-domain.md) - plugins consume core bindings through the contract +- [ADR-016](./016-marten-and-wolverine-infrastructure.md) - plugin handlers run on this infrastructure + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./008-standardized-error-response.md) | [Next](./010-plugin-loading-from-directory.md) diff --git a/Docs/ADR/010-plugin-loading-from-directory.md b/Docs/ADR/010-plugin-loading-from-directory.md new file mode 100644 index 0000000..e90f766 --- /dev/null +++ b/Docs/ADR/010-plugin-loading-from-directory.md @@ -0,0 +1,61 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./009-dynamic-plugin-discovery.md) | [Next](./011-keystore-persisted-as-singleton-marten-document.md) + +# [ADR-010] Load Plugins From Configurable Directory At Startup + +*2026-08* | Status: accepted + +**Tag:** #adr_010 + +**Date:** 2026-08-26 + +**Scope:** Host.Plugins + +## Context + +The host must bring plugin packages online without compile time reference (see ADR-009). The loading step has to happen early enough that infrastructure configured afterwards Wolverine, Marten, MVC, can see the plugin assemblies while it builds its own configuration. + +## Problem + +If plugins are loaded after the host is built, their handlers, controllers, and DI registrations are invisible to the frameworks that already scanned assemblies. The host also needs predictable, low ceremony way to locate each plugin's entry assembly and fail safely when folder is malformed. + +## Decision + +`PluginLoader.LoadPlugins` discovers plugins from configurable `PluginsPath` (default `/plugins`) **before** `WebApplicationBuilder.Build()` is called. Each plugin lives in its own subdirectory whose name must match its entry assembly (`.dll`); the loader reflects public, non-abstract `IAuthKitPlugin` with parameterless constructor, instantiates it, and records it as `LoadedPlugin` (contract + assembly + directory). Assemblies load into `AssemblyLoadContext.Default` (not an isolated context) so framework and package types are shared across the boundary, and hot reload is explicitly not supported. + +### PluginLoader + +**Responsibilities:** + +- Enumerate plugin subdirectories and resolve each entry assembly's dependencies. +- Validate and instantiate the `IAuthKitPlugin` implementation. +- Return only successfully loaded plugin, log and skip invalid folders. + +### LoadedPlugin + +**Responsibilities:** + +- Carry the contract instance, assembly, and source directory for later host use (DI, Wolverine/MVC assembly discovery). + +### Design Rationale + +- Loading before `Build` lets Wolverine, Marten, and MVC discover plugin types during configuration. +- The default load context shares runtime types (Wolverine `IMessageBus`, Marten `IDocumentSession`, MVC types) across the boundary, avoiding type-identity bugs. +- A naming convention plus graceful skip keeps deployment simple and resilient to broken plugin folder. + +## Rejected + +- Loading plugins into isolated `AssemblyLoadContext`s (type-identity mismatches with shared frameworks). +- Loading after the host is built (infrastructure could not see plugin assemblies). +- Supporting hot swapping or unloading of plugins at runtime. +- Requiring manifest file beyond the directory/assembly naming convention. + +## Consequences + +Plugins are online before infrastructure is configured and participate uniformly in DI, messaging, and MVC. The cost is no runtime isolation or reload, and the host trusts plugin assemblies loaded into the default context, so plugin provenance must be controlled operationally. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - the plugin contract being loaded +- [ADR-016](./016-marten-and-wolverine-infrastructure.md) - loaded assemblies feed Wolverine/Marten + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./009-dynamic-plugin-discovery.md) | [Next](./011-keystore-persisted-as-singleton-marten-document.md) From 72f2a3ae91512809d45c7c0ba71a379131810940 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:40:40 +0200 Subject: [PATCH 5/7] docs(adr): document Marten persistence decisions --- ...-persisted-as-singleton-marten-document.md | 54 +++++++++++++++++ ...-token-key-bindings-persisted-in-marten.md | 60 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 Docs/ADR/011-keystore-persisted-as-singleton-marten-document.md create mode 100644 Docs/ADR/012-token-key-bindings-persisted-in-marten.md diff --git a/Docs/ADR/011-keystore-persisted-as-singleton-marten-document.md b/Docs/ADR/011-keystore-persisted-as-singleton-marten-document.md new file mode 100644 index 0000000..be4f878 --- /dev/null +++ b/Docs/ADR/011-keystore-persisted-as-singleton-marten-document.md @@ -0,0 +1,54 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./010-plugin-loading-from-directory.md) | [Next](./012-token-key-bindings-persisted-in-marten.md) + +# [ADR-011] Persist The Encrypted Keystore As Singleton Marten Document + +*2026-08* | Status: accepted + +**Tag:** #adr_011 + +**Date:** 2026-08-26 + +**Scope:** Host.KeyManagement.Repositories + +## Context + +ADR-001 defines `IKeyStoreRepository` as pure persistence sink, and ADR-002 establishes that only encrypted keystore bytes are ever handed to it. The host must decide where those encrypted bytes actually live. + +## Problem + +The encrypted keystore must be durable across restarts, but the repository must not take on cryptography, and the store must be addressable simply - there is exactly one keystore for the whole server. + +## Decision + +`KeyStoreRepository` persists the keystore as single Marten document with fixed id (`"singleton"`), via lightweight sessions. It stores only the encrypted payload (`KeystoreDocument.EncryptedData`); it never encrypts, decrypts, or interprets the content. On load it returns `Memory.Empty` when no document exists on save it inserts or updates the singleton document, copying the caller's bytes into fresh array first. + +### KeyStoreRepository + +**Responsibilities:** + +- Load/save the encrypted keystore as one Marten document identified by constant id. +- Stay cryptography agnostic and use only lightweight sessions. + +### Design Rationale + +- A singleton document matches the "one keystore per server" model and avoids key/lookup management. +- Keeping the repository a dumb encrypted-byte store honors the boundary from ADR-001/ADR-002. +- Marten gives durable, transactional persistence that the rest of the host (Wolverine, plugins) already uses. + +## Rejected + +- A separate flat file or custom blob store outside Marten. +- Multiple keystore documents or per key documents. +- Letting the repository perform encryption or decryption. + +## Consequences + +Key material survives restarts in the same store as the rest of the host's data, and rotation (ADR-001) is just an upsert of the singleton. The cost is hard dependency on Marten/Postgres for key availability and the discipline that the repository never inspects the payload. + +## Related + +- [ADR-001](./001-centralize-signing-key-management.md) - key store abstraction persisted here +- [ADR-002](./002-encrypt-keystore-at-rest.md) - only encrypted bytes are stored +- [ADR-016](./016-marten-and-wolverine-infrastructure.md) - Marten as the host store + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./010-plugin-loading-from-directory.md) | [Next](./012-token-key-bindings-persisted-in-marten.md) diff --git a/Docs/ADR/012-token-key-bindings-persisted-in-marten.md b/Docs/ADR/012-token-key-bindings-persisted-in-marten.md new file mode 100644 index 0000000..cecba7f --- /dev/null +++ b/Docs/ADR/012-token-key-bindings-persisted-in-marten.md @@ -0,0 +1,60 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./011-keystore-persisted-as-singleton-marten-document.md) | [Next](./013-dual-rest-and-grpc-transport.md) + +# [ADR-012] Persist Token Key Bindings In Marten + +*2026-08* | Status: accepted + +**Tag:** #adr_012 + +**Date:** 2026-08-26 + +**Scope:** Host.TokenKeyBindings.Repositories + +## Context + +ADR-004 makes token to signing key bindings core domain, and ADR-011 persists the encrypted signing key material itself as singleton Marten document. The host must decide where binding state lives so it survives restarts alongside the keys it references. + +## Problem + +Bindings link developer tokens to signing keys and support rotation, public key updates, and revocation. If they are kept only in memory they are lost on restart, forcing re-establishment of every token -> key association and breaking verification of already issued tokens after redeploy. The repository must still stay free of domain logic and use the same store the rest of the host already depends on. + +## Decision + +`KeyBindingRepository` persists each `TokenKeyBinding` as Marten document identified by composite id built from the developer token id and signing key id (`"{tokenId:N}:{signingKeyId}"`). It uses lightweight Marten sessions, stores only the serialized binding, and performs no transitions or cryptography. + +### KeyBindingRepository + +**Responsibilities:** + +- Add/get/update/list `TokenKeyBinding` entities via Marten documents. +- Address each binding by its `(tokenId, signingKeyId)` composite identity. +- Stay pure persistence boundary, like `KeyStoreRepository` (ADR-011). + +### KeyBindingDocument + +**Responsibilities:** + +- Carry the Marten document id and the persisted `TokenKeyBinding` payload. + +### Design Rationale + +- Durable persistence keeps token -> key provenance and revocation state across restarts, so issued tokens stay verifiable after redeploy. +- Reusing Marten (ADR-016) avoids second datastore and keeps bindings consistent with the keystore transactionally and operationally. +- A composite document id matches the binding's natural identity and makes `GetAsync` and `ListByTokenAsync` direct lookups/queries. + +## Rejected + +- A separate flat file or custom blob store outside Marten. +- Letting the repository perform binding transitions or encryption. + +## Consequences + +Token key bindings are durable and queryable like the rest of the host's data, and the design stays clean `IKeyBindingRepository` implementation. The cost is hard dependency on Marten/Postgres for binding availability (the same dependency already required by the keystore) and the discipline that the repository never inspects or mutates the binding payload. + +## Related + +- [ADR-004](./004-token-key-bindings-domain.md) - the domain persisted here +- [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) - keystore persisted the same way +- [ADR-016](./016-marten-and-wolverine-infrastructure.md) - Marten as the host store + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./011-keystore-persisted-as-singleton-marten-document.md) | [Next](./013-dual-rest-and-grpc-transport.md) From c858526df4342ea160b4685caafdff875099523e Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:46:48 +0200 Subject: [PATCH 6/7] docs(adr): document transport and error handling decisions --- Docs/ADR/013-dual-rest-and-grpc-transport.md | 47 +++++++++++++++++ .../ADR/014-error-responses-via-middleware.md | 50 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 Docs/ADR/013-dual-rest-and-grpc-transport.md create mode 100644 Docs/ADR/014-error-responses-via-middleware.md diff --git a/Docs/ADR/013-dual-rest-and-grpc-transport.md b/Docs/ADR/013-dual-rest-and-grpc-transport.md new file mode 100644 index 0000000..3b3680f --- /dev/null +++ b/Docs/ADR/013-dual-rest-and-grpc-transport.md @@ -0,0 +1,47 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./012-token-key-bindings-persisted-in-marten.md) | [Next](./014-error-responses-via-middleware.md) + +# [ADR-013] Expose Both REST And gRPC Transport Surfaces + +*2026-08* | Status: accepted + +**Tag:** #adr_013 + +**Date:** 2026-08-26 + +**Scope:** Host + +## Context + +The host must serve API clients with different needs conventional HTTP/JSON consumers and high performance/contract first gRPC clients. Both surfaces sit on top of the same Core domain and plugin contributions. + +## Problem + +Committing to single transport either excludes class of clients or forces an awkward adaptation layer. Running two separate processes duplicates composition, configuration, and operational surface. + +## Decision + +The host is single ASP.NET Core application that exposes both RESTful surface (controllers/MVC via `AddRestfulServices` and `MapAppEndpoints`) and gRPC surface (`AddGrpcServices` and `MapGrpcEndpoints`) from one composed pipeline. Both are built on the same Core services, the same plugin contributions (ADR-009/010), and the same infrastructure (ADR-016). + +### Design Rationale + +- One process and one composition root keep configuration, DI, middleware, and plugin loading shared across transports. +- REST covers broad HTTP/JSON interoperability; gRPC covers typed, low overhead contracts from the same domain. +- A single pipeline avoids drift between what each transport can do. + +## Rejected + +- Exposing only REST or only gRPC. +- Splitting the surfaces into separate deployable processes. +- Tunneling one transport over the other instead of native endpoints. + +## Consequences + +Clients can choose the transport that fits them without second deployment, and both stay consistent with Core. The cost is maintaining two contract sets (OpenAPI and protobuf) and ensuring both surfaces reflect the same domain behavior and plugin-provided security schemes. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - both surfaces expose plugin contributions +- [ADR-014](./014-error-responses-via-middleware.md) - unified error rendering for both transports +- [ADR-015](./015-keycloak-external-jwt-authority.md) - auth protecting both surfaces + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./012-token-key-bindings-persisted-in-marten.md) | [Next](./014-error-responses-via-middleware.md) diff --git a/Docs/ADR/014-error-responses-via-middleware.md b/Docs/ADR/014-error-responses-via-middleware.md new file mode 100644 index 0000000..5e50874 --- /dev/null +++ b/Docs/ADR/014-error-responses-via-middleware.md @@ -0,0 +1,50 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./013-dual-rest-and-grpc-transport.md) | [Next](./015-keycloak-external-jwt-authority.md) + +# [ADR-014] Render HTTP Errors As RFC 7807 Problem Details Via Middleware + +*2026-08* | Status: accepted + +**Tag:** #adr_014 + +**Date:** 2026-08-26 + +**Scope:** Host.Restful.Middleware.Exceptions + +## Context + +ADR-008 establishes that the Core owns shared error vocabulary (`DomainException`, `ErrorResponse`, `ErrorMetadataOptions`). The host must turn failures - domain violations, validation failures, and authorization outcomes - into consistent HTTP shape without leaking internals. + +## Problem + +Scattering error handling inside every controller produces inconsistent responses and risks exposing stack traces. The wire format must be standard and machine readable, and it must stay tied to the Core error vocabulary. + +## Decision + +Three host components centralize HTTP error rendering, all emitting RFC 7807 `ProblemDetails` with `error_code`, `trace_id`, and documentation `type` built from `ErrorMetadataOptions.DocsBaseUrl`: + +- `ExceptionHandlingMiddleware` maps `DomainException` to `409 Conflict` (code derived from the exception type) and any other exception to `500` without internal details. +- `ValidationExceptionMiddleware` maps FluentValidation `ValidationException` to `400 Bad Request` with errors grouped by property. +- `CustomAuthorizationMiddlewareResultHandler` maps challenge/forbidden to `401`/`403`. + +### Design Rationale + +- A middleware pipeline catches errors at one, so controllers stay clean and responses are uniform. +- RFC 7807 is standard problem format understood by HTTP clients and code generators. +- Mapping from `DomainException` keeps the Core error vocabulary (ADR-008) as the single source of domain error meaning, while the host chooses the concrete wire representation. + +## Rejected + +- Returning the Core `ErrorResponse` DTO shape directly as the only HTTP contract (less tooling friendly than ProblemDetails). +- Letting exceptions propagate to the framework default page. +- Exposing exception messages or stack traces for non domain errors. + +## Consequences + +HTTP clients get predictable, standards based error body with codes and trace ids, and domain errors remain authored in Core. The cost is maintaining two related but distinct shapes - the Core `ErrorResponse` vocabulary and the host `ProblemDetails` rendering keeping the mapping in sync as new domain exceptions appear. + +## Related + +- [ADR-008](./008-standardized-error-response.md) - Core error vocabulary rendered here +- [ADR-013](./013-dual-rest-and-grpc-transport.md) - applies to the REST surface + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./013-dual-rest-and-grpc-transport.md) | [Next](./015-keycloak-external-jwt-authority.md) From f45d96b658a589a34c2720ac9e7c6e491e22117f Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Wed, 26 Aug 2026 23:56:51 +0200 Subject: [PATCH 7/7] docs(adr): document Keycloak and Marten/Wolverine architecture --- .../015-keycloak-external-jwt-authority.md | 46 +++++++++ ...016-marten-and-wolverine-infrastructure.md | 51 ++++++++++ Docs/ADR/README.md | 96 +++++++++++++++++++ README.md | 1 + 4 files changed, 194 insertions(+) create mode 100644 Docs/ADR/015-keycloak-external-jwt-authority.md create mode 100644 Docs/ADR/016-marten-and-wolverine-infrastructure.md create mode 100644 Docs/ADR/README.md diff --git a/Docs/ADR/015-keycloak-external-jwt-authority.md b/Docs/ADR/015-keycloak-external-jwt-authority.md new file mode 100644 index 0000000..4572a0e --- /dev/null +++ b/Docs/ADR/015-keycloak-external-jwt-authority.md @@ -0,0 +1,46 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./014-error-responses-via-middleware.md) | [Next](./016-marten-and-wolverine-infrastructure.md) + +# [ADR-015] Use Keycloak As The External JWT Authority + +*2026-08* | Status: accepted + +**Tag:** #adr_015 + +**Date:** 2026-08-26 + +**Scope:** Host.Configuration + +## Context + +The host needs to authenticate callers (including administrative and plugin protected endpoints) using signed JWTs issued by real identity provider, rather than minting or validating tokens itself. + +## Problem + +Rolling local user store or self issued tokens couples the host to identity logic it should not own, and clients expect standard OIDC/JWT bearer flows. The host must validate tokens and surface Keycloak roles to ASP.NET Core authorization without bespoke claim plumbing. + +## Decision + +`AddKeycloakServices` registers ASP.NET Core JWT bearer authentication against Keycloak realm: `Authority` and `Audience` come from `KEYCLOAK_URL`/`KEYCLOAK_REALM`/`KEYCLOAK_CLIENT_ID` (with local dev defaults), issuer and audience validation are on, `NameClaimType` is `preferred_username`, and an `OnTokenValidated` hook maps the Keycloak `resource_access` client roles into standard `ClaimTypes.Role` claims. + +### Design Rationale + +- Delegating to Keycloak keeps the host out of identity issuance and proves tokens via standard JWT bearer validation. +- Mapping `resource_access` roles to role claims lets ordinary ASP.NET Core policies authorize without Keycloak specific code in handlers. +- Environment driven configuration supports dev, test, and production realms from the same code. + +## Rejected + +- A self issued or local token service inside the host. +- A custom user/role database. +- Disabling issuer/audience validation. + +## Consequences + +Callers authenticate with standard Keycloak issued JWTs and role-based policies work uniformly. Two deliberate dev concessions are documented as tradeoffs: `RequireHttpsMetadata = false` and a `DangerousAcceptAnyServerCertificateValidator` backchannel handler - both must be tightened before any production deployment. + +## Related + +- [ADR-013](./013-dual-rest-and-grpc-transport.md) - protects both transport surfaces +- [ADR-014](./014-error-responses-via-middleware.md) - unauthorized/forbidden rendered here + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./014-error-responses-via-middleware.md) | [Next](./016-marten-and-wolverine-infrastructure.md) diff --git a/Docs/ADR/016-marten-and-wolverine-infrastructure.md b/Docs/ADR/016-marten-and-wolverine-infrastructure.md new file mode 100644 index 0000000..06aa136 --- /dev/null +++ b/Docs/ADR/016-marten-and-wolverine-infrastructure.md @@ -0,0 +1,51 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./015-keycloak-external-jwt-authority.md) | [Next]() + +# [ADR-016] Use Marten And Wolverine As Host Infrastructure + +*2026-08* | Status: accepted + +**Tag:** #adr_016 + +**Date:** 2026-08-26 + +**Scope:** Host.Configuration + +## Context + +The host needs document store for durable data (including the keystore from ADR-011 and plugin documents) and message/command handling backbone that can also discover handlers contributed by dynamically loaded plugins (ADR-010). + +## Problem + +Choosing persistence and messaging primitives affects every layer: plugins must be able to participate in the same command pipeline and document store, and validation should be part of message processing rather than scattered. Hand rolling dispatch or mixing ORMs complicates that integration. + +## Decision + +The host standardizes on Marten as the document store and Wolverine as the messaging/command backbone, integrated together: + +- `ConfigureMarten` connects to Marten (connection string `Marten`), `AutoCreate.All` schema, lightweight sessions, and `IntegrateWithWolverine()`; `IDocumentSession` is exposed as scoped service. +- `ConfigureWolverine` uses Wolverine with FluentValidation integrated into message processing, and `IncludeEventHandlers(plugins)` so handlers from the Core assembly and dynamically loaded plugin assemblies are discovered. + +### Design Rationale + +- Marten + Wolverine integration lets document writes and message handling share transactions and one configuration story. +- Discovering handlers from plugin assemblies means solutions (eg. DevTokens) plug into the same command pipeline without host changes. +- FluentValidation in the Wolverine pipeline centralizes command validation before handlers run. + +## Rejected + +- Entity Framework Core or custom ORM as the primary store. +- A hand written command dispatcher instead of Wolverine. +- A separate event/ message store disconnected from the document store. + +## Consequences + +Plugins and Core share one persistence and messaging model, which keeps handlers, documents, and the keystore consistent. The cost is required Postgres/Marten dependency and the learning surface of two frameworks; infra logging for Wolverine/Marten/Npgsql is intentionally suppressed to reduce noise. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin handlers discovered by Wolverine +- [ADR-010](./010-plugin-loading-from-directory.md) - loaded assemblies feed the infrastructure +- [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) - keystore stored in Marten +- [ADR-012](./012-token-key-bindings-persisted-in-marten.md) - bindings stored in Marten + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./015-keycloak-external-jwt-authority.md) | [Next]() diff --git a/Docs/ADR/README.md b/Docs/ADR/README.md new file mode 100644 index 0000000..35ba5d6 --- /dev/null +++ b/Docs/ADR/README.md @@ -0,0 +1,96 @@ +# Architecture Decision Records + +This directory stores AuthKit.Server architectural decisions as ADRs. It is not changelog and not rewritten commit history. It is curated set of technical decisions that explains why the system has its current shape and which constraints that imposes on future work. + +ADRs are grouped by architecture area. Numbering is global, so decision ID does not depend on the file it lives in. Moving a file to another area does not change its number, and the number should be treated as stable decision identifier. + +## How To Read This Collection + +Start with the area that matches what you are changing: + +- if the change affects key management, token key bindings, or core domain contracts, read the `AuthKit.Core` decisions below +- if it affects the REST or gRPC surface, CLI, configuration, or how the server is composed and hosted, see the Host decisions +- if it affects the plugin system, solutions, or plugin abstractions, see the Plugins decisions + +Each ADR contains: + +- `Context`: + the layer and architectural location of the decision +- `Problem`: + the specific technical tension the decision resolves +- `Decision`: + the chosen direction and responsibility boundary +- `Rejected`: + realistic alternatives that were intentionally not selected +- `Consequences`: + maintenance impact, constraints, side effects, and practical implications for future code + +## How To Use ADRs During Changes + +ADRs do not replace reading the code, but they reduce the cost of understanding decisions that have already been made. When you change a given area: + +1. find the matching area +2. read the 2-4 closest ADRs, not just one +3. check whether the new change extends the current model or actually breaks it +4. if decision is no longer true, add a new ADR instead of silently drifting away from the current direction + +This collection is meant to preserve consistency across Core, Host, and Plugins. In AuthKit.Server, much of the cost of change comes from contracts between layers rather than from any single class. + +## Category Map + +The table below shows the architecture areas and their current scope. + +| Area | Scope | +| --- | --- | +| Core | Shared domain model: signing key management, token key bindings, error contracts, and Core interfaces. | +| Host | Application composition and external surface: REST, gRPC, CLI, configuration, and key-material hosting. | +| Plugins | Plugin system: solutions, plugin abstractions, and extension boundaries. | + +## Current ADRs + +| ID | Title | Area | Status | Date | +|----|-------|------|--------|------| +| [ADR-001](./001-centralize-signing-key-management.md) | Centralize Signing Key Management Through A Core Key Store Abstraction | Core | accepted | 2026-08-26 | +| [ADR-002](./002-encrypt-keystore-at-rest.md) | Encrypt Persisted Keystore Material At Rest Through A Pluggable Encryptor | Core | accepted | 2026-08-26 | +| [ADR-003](./003-signing-key-lifecycle-immutable-transitions.md) | Model Signing Key Lifecycle As Immutable State Transitions | Core | accepted | 2026-08-26 | +| [ADR-004](./004-token-key-bindings-domain.md) | Treat Developer Token To Signing Key Bindings As A Core Domain | Core | accepted | 2026-08-26 | +| [ADR-005](./005-public-keys-via-jwks.md) | Publish Public Keys Through JWKS Exposing Only Non-Revoked Keys | Core | accepted | 2026-08-26 | +| [ADR-006](./006-kid-as-generated-guid.md) | Derive The JWT Key Identifier As A Generated GUID | Core | accepted | 2026-08-26 | +| [ADR-007](./007-default-signing-algorithm-rsa-4096.md) | Default Signing Algorithm Is RSA-4096 With RS256 | Core | accepted | 2026-08-26 | +| [ADR-008](./008-standardized-error-response.md) | Standardize API Errors Through A Core Error Response Contract | Core | accepted | 2026-08-26 | +| [ADR-009](./009-dynamic-plugin-discovery.md) | Discover And Load Plugins Dynamically Through The IAuthKitPlugin Contract | Plugins | accepted | 2026-08-26 | +| [ADR-010](./010-plugin-loading-from-directory.md) | Load Plugins From A Configurable Directory At Startup | Host | accepted | 2026-08-26 | +| [ADR-011](./011-keystore-persisted-as-singleton-marten-document.md) | Persist The Encrypted Keystore As A Singleton Marten Document | Host | accepted | 2026-08-26 | +| [ADR-012](./012-token-key-bindings-persisted-in-marten.md) | Persist Token Key Bindings In Marten | Host | accepted | 2026-08-26 | +| [ADR-013](./013-dual-rest-and-grpc-transport.md) | Expose Both REST And gRPC Transport Surfaces | Host | accepted | 2026-08-26 | +| [ADR-014](./014-error-responses-via-middleware.md) | Render HTTP Errors As RFC 7807 Problem Details Via Middleware | Host | accepted | 2026-08-26 | +| [ADR-015](./015-keycloak-external-jwt-authority.md) | Use Keycloak As The External JWT Authority | Host | accepted | 2026-08-26 | +| [ADR-016](./016-marten-and-wolverine-infrastructure.md) | Use Marten And Wolverine As Host Infrastructure | Host | accepted | 2026-08-26 | + +## Relationships Between Areas + +The most common architectural flow in AuthKit.Server looks like this: + +`Core -> Host -> Plugins` + +This is not strict dependency diagram of the project, but it is useful map for reading decisions. In practice: + +- `Core` defines what the system considers to be domain data and the cryptographic contracts (keys, bindings, errors) +- `Host` defines how that domain is exposed and composed into a running server (API surfaces, CLI, configuration) +- `Plugins` extend behavior on top of the stable Core and Host contracts + +## When To Add A New ADR + +A new ADR is worth adding when change: + +- shifts responsibility boundaries between layers +- introduces a new data contract or new durable artifact +- changes the execution model of key management, hosting, or plugin loading +- adds a new provider or plugin specific behavior that no longer fits the current model +- replaces an earlier decision with a different conscious trade off + +It is usually not worth adding an ADR for: + +- an ordinary refactor without an architectural change in direction +- a cosmetic local API change limited to one file or class +- a documentation or test only adjustment that does not change system contracts diff --git a/README.md b/README.md index cf871b5..69855a8 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ The host listens on the address configured in `Server:Host` (default `http://0.0 | --- | --- | | Documentation index | [Docs](Docs/README.md) | | Schemas & Diagrams | [Schemas](Docs/Schemas.md) | +| Architecture Decision Records | [ADRs](Docs/ADR/README.md) | ## Contributing