From e92c54a1f92b8fbd639a03ad1aed33cc179ff9df Mon Sep 17 00:00:00 2001 From: vivet Date: Tue, 15 Sep 2026 20:18:40 +0200 Subject: [PATCH 01/10] Updated copilot prompts. --- ...ano-add-api-client-configuration.prompt.md | 143 ++++++++ ....md => nano-add-custom-endpoint.prompt.md} | 135 ++++---- .github/prompts/nano-add-entity.prompt.md | 305 ++++++++++++++++++ .../prompts/nano-define-api-client.prompt.md | 117 ------- ...-remove-api-client-configuration.prompt.md | 69 ++++ .../nano-remove-custom-endpoint.prompt.md | 196 +++++++++++ .github/prompts/nano-remove-entity.prompt.md | 96 ++++++ .../prompts/nano-scaffold-entity.prompt.md | 158 --------- .../nano-undefine-api-client.prompt.md | 56 ---- .github/workflows/build-and-deploy.yml | 2 +- README.md | 5 +- sync-agents-md.ps1 | 11 +- 12 files changed, 891 insertions(+), 402 deletions(-) create mode 100644 .github/prompts/nano-add-api-client-configuration.prompt.md rename .github/prompts/{nano-scaffold-custom-endpoint.prompt.md => nano-add-custom-endpoint.prompt.md} (84%) create mode 100644 .github/prompts/nano-add-entity.prompt.md delete mode 100644 .github/prompts/nano-define-api-client.prompt.md create mode 100644 .github/prompts/nano-remove-api-client-configuration.prompt.md create mode 100644 .github/prompts/nano-remove-custom-endpoint.prompt.md create mode 100644 .github/prompts/nano-remove-entity.prompt.md delete mode 100644 .github/prompts/nano-scaffold-entity.prompt.md delete mode 100644 .github/prompts/nano-undefine-api-client.prompt.md diff --git a/.github/prompts/nano-add-api-client-configuration.prompt.md b/.github/prompts/nano-add-api-client-configuration.prompt.md new file mode 100644 index 00000000..266d57e3 --- /dev/null +++ b/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -0,0 +1,143 @@ +--- +mode: agent +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +--- + +# Nano add API client configuration + +Wires an *already-defined* Api Client - a `BaseApiClient` subclass living in the target +service's `{Name}.Models` project - into this consuming application: the `App:Apis` config entry +and the injection site that actually makes it callable. Read AGENTS.md's `### Api Clients` +section first - it documents the built-in method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) +and authentication forwarding in full; this skill does not repeat that, only how to consume a +client from this app. + +If the client class doesn't exist yet, don't treat that as a choice to offer - treat it as a sign +something may be wrong. Either the user is pointed at the wrong application (the client is +expected to already exist, defined on the *owning* service's side - check this isn't simply the +wrong project before going further), or the owning service genuinely hasn't defined it yet, in +which case that's `nano-add-api-client`'s job, in that other application's own project, not +this skill's. Either way: stop, warn the user plainly that the client doesn't exist where expected, +and ask which is true - don't invoke the other skill automatically, and don't proceed on the +assumption a missing class is just an item to create in passing. + +**No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name +matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's +never actually injected anywhere doesn't get registered at all. Add the config, then make sure +something actually consumes it (a controller or worker constructor parameter), or none of this +takes effect. + +## Before making any change, determine + +1. **Does the client class already exist?** Check the target service's `{TargetName}.Models/Api/` + project (or its published NuGet) for the `BaseApiClient`/`BaseIdentityApiClient` subclass the + user means. If it doesn't exist yet, stop - don't create it inline, and don't invoke + `nano-add-api-client` automatically. Warn the user explicitly that the client isn't defined + where expected, and ask two things: is this actually the right target/application to be + wiring into right now, and if so, did they mean to create the client first (on the owning + service's own project, a separate task from this one)? Let them answer both before doing + anything else. +2. **How does this app reference the target's `.Models` project?** Check how any other Api + Client in this project already references its target (`ProjectReference` for a + same-solution/monorepo target, or a NuGet/private-feed `PackageReference` for a separate-repo + target - the more common real-world case, since most Nano apps live in separate repos and + publish their `.Models` project as a private package) and match that convention - AGENTS.md + explicitly allows either here. If this is the first Api Client in the project, ask which + applies. + - **Then actually add the reference to this app's `.csproj` if it isn't already there** - don't + stop at determining which kind it should be. A missing reference here is a real, previously + observed gap (this app referencing a target's Api Client with no corresponding + `ProjectReference`/`PackageReference` at all, caught only when the build failed). + - **For a `PackageReference`, determine the version rather than guessing.** If the target's + source is locally available (a monorepo or multiple repos checked out side by side), read + its `.csproj`'s `` directly. If it isn't, ask the user for the version instead of + inventing one. + - **Private feed authentication is the user's responsibility, not something to work around.** + If the target's package lives on a private feed (Azure Artifacts, GitHub Packages, etc.) and + restore fails for missing credentials, say so plainly and let the user resolve their own + `nuget.config`/feed auth - don't attempt to supply or guess at credentials yourself, and + don't silently fall back to a different reference shape to dodge the failure. +3. **Is a client with this class name already registered?** Check for an existing `App:Apis` key + matching the class name - if one's already there pointing at a different host/target, confirm + with the user before overwriting it. +4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no + inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted + client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` + configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. + +## appsettings.json (this app) + +Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key +must be the exact class name from step 1: + +```json +"App": { + "Apis": { + "MyApi": { + "Host": "my-service", + "Root": "api", + "Port": 8080, + "UseSsl": false, + "Timeout": "00:00:30" + } + } +} +``` + +- `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose + service name locally, if calling another app in the same compose network) - not sensitive, + stays in the base file. +- Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own + `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other + provider's health check. +- **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, + full `administrator` identity** on the target - not a lesser scope, the same as any human root + login. That's exactly why it's worth using instead of just making the target endpoint + anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` + call still flows through the target's normal authorization *and* shows up in its audit log as + root having acted - the right choice whenever the target also serves real authenticated + end-users, or attribution of machine-to-machine calls matters. But because it's full admin + access, the credential needs the same secret-handling rigor as anything else that powerful - + don't hardcode it in the base `appsettings.json`; set the real value only in + `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret + in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. + **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match + the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target + creates, never re-create or duplicate it with a new name. **But don't assume that secret + already exists** - per `nano-add-authentication-jwt`, a Staging/Production `RootLogin` is not + something the target gets by default; it's an opt-in a human has to hand-wire on the target + app specifically, which is a different repo this skill has no visibility into. Ask the user to + confirm the target actually has `auth-root-login-secret` (keys `root-login-username`/ + `root-login-password`) wired up in Staging/Production before adding a reference to it here - + don't wire a `secretKeyRef` that may point at a secret nothing creates. Once confirmed, map it + into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` in `deployment.yaml`. Don't + confuse this with `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; + they're on different apps, in different directions. + +## Injecting the client + +Inject the client class directly into whatever consumes it - a controller or worker constructor +parameter: + +```csharp +public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) +{ + // ... +} +``` + +This is the step that actually makes the `App:Apis` entry take effect - without it, per the +gotcha above, nothing gets registered even though the config exists. + +## After making the change + +- Show the user every file touched in *this* app - the `.csproj` reference (if one was added), + the `appsettings.json` addition, and the injection site. Note that the client class itself + lives in the target service's `.Models` project, not here. +- Confirm the client is actually injected somewhere - if the request was just "add the client" with + no specified consumer yet, say explicitly that nothing is wired up until it's referenced. +- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let + a real credential sit in the base file - and whether the target's `auth-root-login-secret` was + confirmed to actually exist or is still an open prerequisite on that other app. +- If restore failed due to private feed authentication, say so plainly and stop there - that's + the user's `nuget.config`/feed credentials to fix, not something to route around. diff --git a/.github/prompts/nano-scaffold-custom-endpoint.prompt.md b/.github/prompts/nano-add-custom-endpoint.prompt.md similarity index 84% rename from .github/prompts/nano-scaffold-custom-endpoint.prompt.md rename to .github/prompts/nano-add-custom-endpoint.prompt.md index c22a5b80..b6939da6 100644 --- a/.github/prompts/nano-scaffold-custom-endpoint.prompt.md +++ b/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -3,28 +3,28 @@ mode: agent description: Scaffold a custom, non-CRUD HTTP endpoint end-to-end - two genuinely different shapes depending on the controller. A Public API endpoint composes existing Api Client calls into one response. An internal-service endpoint implements real logic against this app's own IRepository/IEventing, and - since it's a contract another application will call - also scaffolds the paired Api Client custom request/method that calls it, in the same change. Use when the user asks to add a one-off action, custom endpoint, or operation that doesn't fit generic CRUD/Auth/Audit/Identity to a Nano API or Web application. --- -# Nano scaffold custom endpoint +# Nano add custom endpoint Generates a single custom HTTP action that doesn't fit the generic CRUD/Auth/Audit/Identity -surface `nano-scaffold-entity` and the built-in Api Client method groups already cover. Read +surface `nano-add-entity` and the built-in Api Client method groups already cover. Read AGENTS.md's `### Controllers` (including its `#### Public API vs internal service` note) and -`### Api Clients` sections first; this prompt does not repeat those, only how to combine them +`### Api Clients` sections first; this skill does not repeat those, only how to combine them following this solution's own established conventions (one-liner XML doc summaries, `[ProducesResponseType]` per status code, `Requests/`/`Responses/` folders matching the controller's own namespace). -**Two genuinely different jobs, not one prompt with an optional extra step.** A Public API -endpoint composes calls that already exist elsewhere; an internal-service endpoint *is* a new -piece of contract another application will call, so scaffolding it also means scaffolding the -client-side half of that same contract - one coherent task, not two prompts chained together. -**Step 2** below determines which applies; read only the matching path once it's decided. +**Two genuinely different jobs, not one skill with an optional extra step.** A Public API endpoint +composes calls that already exist elsewhere; an internal-service endpoint *is* a new piece of +contract another application will call, so scaffolding it also means scaffolding the client-side +half of that same contract - one coherent task, not two skills chained together. **Step 2** below +determines which applies; read only the matching path once it's decided. -⚠ **Terminology**: this prompt calls the customer/end-user-facing role "**Public API**" (e.g. -`Api.Platform`/`Api.Admin` in this solution), never "gateway." "Gateway" in this codebase means -the Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the -network-edge/cert-manager TLS layer in front of a cluster - an unrelated, infrastructure-level -concept. Don't reuse that word for this application-level role, in code, comments, or -conversation with the user. +⚠ **Terminology**: this skill calls the customer/end-user-facing role "**Public API**" (e.g. +`Api.Platform`/`Api.Admin` in this solution - this solution's own project template for one is +`nanocore-api-public`), never "gateway." "Gateway" in this codebase means the Kubernetes Gateway +API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) or the network-edge/cert-manager +TLS layer in front of a cluster - an unrelated, infrastructure-level concept. Don't reuse that word +for this application-level role, in code, comments, or conversation with the user. --- @@ -61,20 +61,21 @@ insufficient - not just "less convenient." Walk through this before scaffolding the right call for a custom endpoint, not a sign to keep looking for a generic-composition way around it. - **The same generic-composition workaround needed at 2+ call sites is itself a signal to stop - composing and build the real custom endpoint.** A union across two entity types done as two - generic calls glued together in one Public API action is fine the first time; the same two calls - duplicated again in a second and third action is a sign the composition belongs on the *target* - service as a real custom endpoint instead - one round trip, one place the logic lives, instead of - the same non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before + composing and build the real custom endpoint.** A union across two entity types (e.g. "roles + owned by this tenant, plus roles reachable via its subscription plan") done as two generic calls + glued together in one Public API action is fine the first time; the same two calls duplicated + again in a second and third action is a sign the composition belongs on the *target* service as + a real custom endpoint instead - one round trip, one place the logic lives, instead of the same + non-trivial join reimplemented at every caller. Don't wait for a fourth duplicate before promoting it. - **Before scaffolding a single-item lookup, check whether a sibling list/aggregate endpoint - already makes it redundant.** If a "get all X for this owner" endpoint already returns every - item with what the caller needs populated, a separate "get one" endpoint often isn't pulling its - weight - the caller can fetch or already has the list and pick the one entry it wants. This isn't - a hard rule (a list that's expensive to fetch, or a route that needs to 404 on a specific id - rather than filter client-side, can still justify keeping both), but don't scaffold the - single-item version reflexively just because a list version exists; ask whether it earns its own - endpoint. + already makes it redundant.** If a "get all roles for this tenant" endpoint already returns every + role with `RolePermissions` (or whatever the caller needs) populated, a separate "get one role" + endpoint often isn't pulling its weight - the caller can fetch or already has the list and pick + the one entry it wants. This isn't a hard rule (a list that's expensive to fetch, or a route that + needs to 404 on a specific id rather than filter client-side, can still justify keeping both), + but don't scaffold the single-item version reflexively just because a list version exists; ask + whether it earns its own endpoint. - **Is the actual need "the generic write plus an invariant that must always hold," not a new route at all?** If what's missing is validation before a create/edit, a linked-entity side-effect after one, or a guard before a delete *or a create* (e.g. rejecting a new child row once a @@ -145,31 +146,32 @@ DTO comes up in either path: bad request is rejected by model binding before it ever reaches an Api Client call or a repository write, instead of surfacing as a downstream 400/500. - **Check for an existing sibling DTO with the same shape before defining a new one.** A response - that just repeats a handful of scalar fields from one entity probably already has a matching - `Response` somewhere in the same project - reuse it rather than defining a - near-duplicate. This applies across paths too: if an internal-service change makes a Public API's - existing bespoke response DTO redundant (e.g. an indirection layer it existed to route around gets - removed), that's a real signal to delete the bespoke DTO and switch the caller to the sibling one, - not to keep both. + that just repeats `Id`/`Name`/`Description`/`CreatedBy`/`PermissionKeys` from `Role` probably + already has a `RoleResponse` (or equivalent) somewhere in the same project - reuse it rather than + defining a near-duplicate. This applies across paths too: if an internal-service change makes a + Public API's existing bespoke response DTO redundant (e.g. an indirection layer it existed to + route around gets removed), that's a real signal to delete the bespoke DTO and switch the caller + to the sibling one, not to keep both. - **Default to returning the entity (or collection of entities) directly - reach for `[Include]` to stitch together whatever the response needs before reaching for a custom Response DTO.** A custom endpoint's job is usually a query or a write too specific for generic `.Entity`, not a shape too - specific for the entity itself. Return that type directly - - `[ProducesResponseType(typeof(MyEntity[]), ...)]`, `this.Ok(entities)` - not wrapped in a - `Response` that just repeats the same properties. Building a custom Response DTO is valid, - but treat it as the *last resort*: reach for it when the shape genuinely can't come from the - entity plus `[Include]` (computed/aggregated fields not stored anywhere, derived at read time - rather than persisted, or a flattened projection across more than one unrelated entity graph) - - not by default, and not just because it's the response of a custom action. A Public API that - wants its *own* shaped DTO still maps the raw entity into one on its own side; that's not a - reason for the target service's endpoint to invent one first. + specific for the entity itself - the response is still an ordinary `Role`/`IEnumerable` with + the right navigations eager-loaded. Return that type directly - + `[ProducesResponseType(typeof(Role[]), ...)]`, `this.Ok(roles)` - not wrapped in a `Response` + that just repeats the same properties. Building a custom Response DTO is valid, but treat it as + the *last resort*: reach for it when the shape genuinely can't come from the entity plus + `[Include]` (computed/aggregated fields not stored anywhere - e.g. "is this plan referenced by any + Subscription," derived at read time rather than persisted - or a flattened projection across more + than one unrelated entity graph) - not by default, and not just because it's the response of a + custom action. A Public API that wants its *own* shaped DTO still maps the raw entity into one on + its own side; that's not a reason for the target service's endpoint to invent one first. - **If the response leans on nested navigations, every level of that chain needs `[Include]`, not just the top one.** Per AGENTS.md's Response Serialization section, a navigation only appears in the JSON response when it's both loaded (via `includeDepth`) *and* tagged `[Include]` on the property itself - and this applies independently at every level of the graph. A response built by - walking through two or three levels of navigation needs `[Include]` on each of those navigation - properties, not just the first one; skipping a middle link means that step silently comes back - empty even though the ends are tagged correctly. Trace the exact path the response + walking `Role → SubscriptionPlanRoles → Role → RolePermissions` needs `[Include]` on each of those + navigation properties, not just the first one; skipping a middle link means that step silently + comes back empty even though the ends are tagged correctly. Trace the exact path the response constructor/mapping actually walks and confirm every property on it is tagged before assuming `[Include]` "already covers this." @@ -185,18 +187,18 @@ Client method of its own. Check whether the Api Client(s) this action needs are already injected in this controller (or injectable without issue) and whether the specific call needed is already a generic method or an existing custom method - including a custom method on the *target* service's own controller that -already computes the exact union/aggregate this action needs, rather than re-deriving the same -result here via several generic calls. +already computes the exact union/aggregate this action needs (e.g. an existing "get all roles +available to a tenant" internal-service action), rather than re-deriving the same result here via +several generic calls. If a **new custom Api Client method** is needed and doesn't exist yet: **stop and ask the user whether to create it now**. That method's controller action lives on the *target* service - a different application than this Public API. If the target's Api Client class doesn't exist at all -yet, that's `nano-define-api-client`'s job, on the target's own project; if the whole -custom-endpoint contract (controller action + client method) doesn't exist yet, that's *this -prompt's own Internal service path*, run against the target application, not this one. Don't -invoke either automatically, and don't scaffold this Public API action against a method that -doesn't exist yet as if it already does - proceed here only once the user has confirmed -whether/how that gets created elsewhere. +yet, that's `nano-add-api-client`'s job, on the target's own project; if the whole custom-endpoint +contract (controller action + client method) doesn't exist yet, that's *this skill's own Internal +service path*, run against the target application, not this one. Don't invoke either automatically, +and don't scaffold this Public API action against a method that doesn't exist yet as if it already +does - proceed here only once the user has confirmed whether/how that gets created elsewhere. **Don't wrap a plain generic call - or a plain generic *composition* - in a new custom Api Client method.** If the backing call is already `.Entity`/`.Auth`/`.Audit`/`.Identity` with no shaping or @@ -297,8 +299,8 @@ request/method that lets other applications actually call it. Check `{ThisApp}.Models/Api/` for an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If none exists, create the bare class inline as part of this same change - it's boilerplate with no -decision to make (see `nano-define-api-client`'s "Client class" shape), not a reason to stop and -chain into a separate prompt. +decision to make (see `nano-add-api-client`'s "Client class" shape), not a reason to stop and +chain into a separate skill. ### Shared body model @@ -351,11 +353,10 @@ only in the two cases where inference can't land correctly: a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* controller than the one the response type's name would imply. -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - check this deliberately rather than assuming inference works. +In this solution specifically, most custom requests so far have hit the second case (see +`GetTenantDomainRequest`: its response is the `TenantDomain` entity, but the action lives on +`TenantsController`, not a dedicated `TenantDomainsController`) - check this deliberately rather +than assuming inference works. **Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and reference it from both this request's action attribute and the controller action's `[Route(...)]` @@ -386,7 +387,7 @@ Clients gotchas, a non-success response never throws for a plain 404 - it return from an empty collection (found, nothing to return) with no extra plumbing. Have the controller action return `this.NotFound()` for the not-found case explicitly, and resist collapsing the client method's `null` into `[]` "for convenience" - that throws away the exact distinction the -caller needs. +caller needs (e.g. a tenant that doesn't exist vs. a real tenant with no roles yet). **The method's parameter is the shared body model itself, not its properties spread out as separate scalar parameters.** `MyActionAsync(MyAction model, ...)` above, not @@ -438,7 +439,7 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc - Add to an existing entity controller, or create a new one (`BaseController`, or the appropriate entity controller base per AGENTS.md's Entity controller hierarchy) if none exists yet - follow - `nano-scaffold-entity`'s controller-file conventions for a brand new controller's shape/naming + `nano-add-entity`'s controller-file conventions for a brand new controller's shape/naming (correct base tier, naming, eventing-parameter handling). This includes the retrofit case: an entity that already exists but has no generic controller yet still gets its full generic controller as part of creating it here - this action doesn't replace or narrow that entitlement. @@ -463,10 +464,10 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc not-found case that specifically needs a message/code rather than a bare 404: `Nano.Data.Abstractions.Exceptions.NotFoundException`. - **Don't narrow an entity's controller tier to dodge a route collision with this action - flag it - instead.** The controller's tier (full CRUD by default, per `nano-scaffold-entity`) doesn't - change because a custom action sits alongside it. If this action's route+verb is identical to a - route the generic tier also exposes, that's a genuine defect in the request-side contract (one of - the two routes needs to change) - add the action anyway, with a prominent comment naming exactly + instead.** The controller's tier (full CRUD by default, per `nano-add-entity`) doesn't change + because a custom action sits alongside it. If this action's route+verb is identical to a route + the generic tier also exposes, that's a genuine defect in the request-side contract (one of the + two routes needs to change) - add the action anyway, with a prominent comment naming exactly which generic route it collides with (verb + path + which AGENTS.md table row), and leave both in place for the user to resolve. - **Check built-in routes on narrower base classes too, not just the generic CRUD table** - e.g. @@ -492,8 +493,8 @@ public virtual async Task MyActionAsync([FromBody][Required] MyAc The case above generalizes into a real alternative to scaffolding a new custom action: whenever the actual requirement is "the same generic write, plus an invariant that must hold no matter which caller/route triggers it" - validation before a create/edit, a linked-entity side-effect after one, -a reference-count guard before a delete *or before a create* (e.g. a parent whose children must -stop being addable, not just removable, once another entity references it) - override the relevant +a reference-count guard before a delete *or before a create* (e.g. a plan whose Roles must stop +being addable, not just removable, once any Subscription references it) - override the relevant `BaseEntityController<...>` method(s) directly rather than adding a parallel custom action next to them. This enforces the rule as a property of the *entity's own controller*, so it holds for every consumer, not just the one Public API that remembered to compose it. diff --git a/.github/prompts/nano-add-entity.prompt.md b/.github/prompts/nano-add-entity.prompt.md new file mode 100644 index 00000000..1a09afcc --- /dev/null +++ b/.github/prompts/nano-add-entity.prompt.md @@ -0,0 +1,305 @@ +--- +mode: agent +description: Scaffold a new Nano.Library entity - data model and EF Core mapping, plus query criteria and a CRUD controller for API/Web applications - following Nano framework conventions. Use when the user asks to add a new entity, resource, or CRUD endpoint to a Nano-based application (a project with an AGENTS.md describing Nano, or that references Nano.Library/NanoCore NuGet packages). +--- + +# Nano add entity + +Generates the files Nano needs for a new entity: data model and EF Core mapping always; query +criteria and a CRUD controller too, unless the target is a Console application (Console apps +have no HTTP surface, so there's nothing for either of those to serve - see step 3 below). Read +`AGENTS.md` in the target repo root first if present - it documents the exact base classes and +gotchas for that specific solution; this skill assumes the general Nano.Library conventions and +defers to a project's own AGENTS.md on any conflict. + +## Before generating anything, determine + +1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()` and confirm a `DbContext` exists in the project (e.g. `Data/DbContext.cs`). + An entity's mapping only takes effect once Nano's `MapEntities` discovery attaches + it to a registered context - with no Data provider, the generated files would be dead code + with nothing to persist them. If none is registered, stop and tell the user a Data provider + needs to be added first; don't generate the entity anyway "for later." +2. **Entity name and properties.** Ask the user if not already given in the request - need + at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar + properties (name + type). Don't invent business fields that weren't asked for. +3. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or + `NanoConsoleApplication`. + - **API or Web**: generate all four files below. + - **Console**: generate only File 1 (data model) and File 2 (mapping) - skip Files 3 and 4 + entirely (query criteria and controllers are API-request concepts; a Console app has + nothing to route them to). Confirm this with the user only if they explicitly asked for a + controller or query criteria on a Console app - otherwise just skip silently; a Console + app's data folder only ever needs `Data/Models/` and `Data/Mappings/`, never + `Controllers/`/`Criterias/`. +4. **Project layout.** Look for a `.Models` project alongside the main app project + (check the `.sln` or list sibling folders). + - **Split layout** (a `.Models` project exists): entity model and query criteria (if + applicable) go in the `.Models` project (they're part of the API client contract other + services consume); the mapping and controller (if applicable) go in the main app project. + - **Single-project layout** (no `.Models` project): all files go in the one app project. +5. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the + project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing + entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a + non-`Guid` identity unless the project already uses one consistently - it's a + cross-cutting decision (affects the entity, mapping, controller, repository calls, + and API client), not something to add on a whim for one entity. +6. **Existing conventions.** Skim one existing entity/mapping (and controller, if + applicable) triplet in the project (if any exist) for property style, nullable-reference + usage, and namespace layout, and match it. +7. **Every entity gets a generic controller - full stop, independent of whatever else exists for + it.** This is not conditional on a query criteria class already existing, and **not conditional + on whether an Api Client happens to reference the entity** - that's a separate concern this + skill doesn't judge. When retrofitting controllers onto entities that already exist: for each + entity with no controller yet, generate the query criteria class first if one doesn't already + exist (File 3), then the controller against it (File 4) - every entity, not just the ones an + Api Client happens to call out. **If a controller already exists for an entity, don't recreate + it** - move on to the next entity. + + This skill scaffolds the generic substrate only - it doesn't search for or reason about custom + Api Client requests that might target this entity's controller. Whether a pre-existing custom + request already points here (only possible when retrofitting a controller onto an entity that + already existed) - and, if so, how its stub action gets scaffolded, named, and checked for + route collisions - is `nano-add-custom-endpoint`'s job, including creating the controller + itself inline if this skill hasn't been run yet. That skill already owns + controller-resolution, route-constant conventions, and collision/override handling; + duplicating any of that judgment here would just be two places for the same rule to drift + apart. +8. **Is this entity `[Publish]`d, `[Subscribe]`d, or neither?** (AGENTS.md's `### Entity Events`) + Ask if it isn't already clear from the request - this changes both the CRUD tier (File 1/File + 4) and, for `[Subscribe]`, the entity's actual shape: + - **`[Publish]`**: a normal entity, full CRUD by default, additionally marked `[Publish](...)` + with the property paths other applications are allowed to replicate. Only add paths the + request actually asked to expose - don't publish every scalar property by default. + - **`[Subscribe]`**: this entity's shape is **not** something to invent from user-provided + field names - it must mirror the actual publisher. Locate the source entity's `[Publish]` + attribute (if it's locally visible, e.g. another app in this same workspace) and derive: + the class name (must match the publisher's `TypeName` - its published type's simple class + name), and the properties (the **leaf segment of each publish path**, flattened/denormalized, + not a structural copy of the source's navigation shape - see AGENTS.md's Subscribing + example). If the publisher isn't locally visible, ask the user for the exact `TypeName` and + leaf property names/types instead of guessing a shape that has to match byte-for-byte for + the eventing handler to find it. See File 1 below for the resulting CRUD-tier restriction. + - **Neither**: a normal entity, full CRUD by default, no Entity Events involvement. + +## File 1 - Data model + +Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` +in the single-project layout. + +```csharp +public class : BaseEntity +{ + public string Name { get; set; } = null!; + // ...other scalar properties as requested +} +``` + +- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's + existing convention (see step 5 above). +- For restricted CRUD (e.g. read-only, or no delete), derive instead from + `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, + `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request + implies one of these rather than full CRUD. +- **`[Subscribe]` entities are restricted CRUD by convention, not a case to ask about.** Per step + 8, a `[Subscribe]` entity is a local replica kept in sync by the built-in + `EntityEventingHandler` whenever the publishing app's source entity changes - update/delete + normally happen through that Subscribe mechanism, not through this app's own HTTP surface. Use + `BaseEntityCreatable` for the entity and `BaseEntityCreatableController` for its controller + (File 4) regardless of what tier was otherwise requested - Edit/Delete shouldn't be exposed to + callers on a subscribed entity. The entity's shape itself (class name, properties) comes from + step 8's publisher lookup, not from this skill's normal "ask the user for scalar properties" + step 2 - don't invent field names for a `[Subscribe]` entity. +- **`[Publish]` entities** get the attribute per step 8, with no change to CRUD tier or shape - + they're scaffolded exactly like any other entity, just additionally marked for replication. +- Use `required`/`= null!` per the project's existing nullable-reference style, not your own + default. +- **Every `string` property gets an explicit `[MaxLength(n)]`** - never leave a string column + unbounded. Pick `n` from what the property actually represents, not one number applied + mechanically: a `Name` is usually fine at `128`, an email address or URL wants more (`256`), a + short code/abbreviation wants less (`32`/`16`), free-form notes/descriptions may need more still + - use `128` as the reasonable default when nothing about the property's own meaning suggests a + different size, not as a rule to apply without thinking. This annotation and File 2's + `.HasMaxLength(n)` must agree - see File 2's note on keeping both in sync. +- **`bool` and `enum` properties get an explicit default**, via `[DefaultValue(...)]` on the + property, matching whatever the property is initialized to in the class (`= true`/ + `= MyEnum.SomeMember`) - don't leave a `bool`/`enum` property's default implicit (C#'s own + `false`/first-member-value default) without stating it, since that's exactly the kind of + intent that's invisible on read until it's a production surprise. File 2's `.HasDefaultValue(...)` + must match the same value. + +## File 2 - Data mapping + +Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in +the split layout - mappings are EF Core-only, never part of the shared API client models). + +```csharp +public class Mapping : BaseEntityMapping<> +{ + public override void Configure(EntityTypeBuilder<> builder) + { + ArgumentNullException.ThrowIfNull(builder); + + base.Configure(builder); + + builder + .Property(x => x.Name); + } +} +``` + +- **Always call `base.Configure(builder)`** before your own configuration - omitting it + silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single + most common mistake when writing a mapping by hand. +- **Configure every one of the entity's own properties explicitly - including navigations and + collections - never rely on EF's conventions to fill in what isn't written down.** An implicit, + convention-inferred relationship is exactly the kind of mistake that's invisible until it's a + production bug (e.g. EF silently creating a shadow FK column for a stray navigation property + with no real relationship behind it). Being explicit is what makes a mistake visible on read, + not what EF happens to guess correctly most of the time. +- **List properties in the same order they're declared on the entity** - one `.Property(...)`/ + `.HasOne(...)`/`.HasMany(...)` block per property, top to bottom, matching the class. This + makes the mapping file scannable against the entity file side by side: a missing or + out-of-place property is immediately visible, not something that only surfaces when something + breaks at runtime. + - **Scalar property**: `.Property(x => x.Y)`, with `.IsRequired()` matching the property's own + nullability. For a `string`, always add `.HasMaxLength(n)` matching File 1's `[MaxLength(n)]` + exactly - the two must agree; a mismatch means the database allows more than the API's own + model validation does, or vice versa. For a `bool`/`enum` property, add + `.HasDefaultValue(...)` matching File 1's `[DefaultValue(...)]` and the property's own C# + default - explicit at the database level too, not just in the model. + - **FK scalar + its reference navigation** (usually adjacent in the entity): one combined + `.HasOne(x => x.Nav).WithMany(...)/.WithOne(...).HasForeignKey(x => x.FkId)` block, positioned + where the pair sits in the property order, **plus an explicit `.OnDelete(DeleteBehavior.…)`** + on the same chain - never leave delete behavior to EF's own inferred default (`Cascade` for a + required/non-nullable FK, `ClientSetNull` for an optional one). State it explicitly even when + the desired behavior happens to match what EF would infer anyway: the point is that a reader + (or a future edit) sees the intended behavior written down, not that it produces a different + result today. Pick the value deliberately per relationship - `Cascade` when the dependent + genuinely can't exist without the parent (most owned child rows), `Restrict` when deleting the + parent while dependents still exist should be a hard error instead of silently taking them + with it, `SetNull` only for a genuinely optional reference. Don't default to `Cascade` + everywhere just because it's often EF's own inferred behavior for required FKs. + - **Inverse collection/reference navigation with no FK of its own** (the principal side of a + relationship whose FK is declared in the *dependent* entity's own mapping): configure it + explicitly here too, not just with a comment - `.HasMany(x => x.Children).WithOne(x => x.Parent)` + (or `.HasOne(...).WithOne(...)` for a 1:1 principal side), with `.IsRequired()` added whenever + the dependent's own FK property is non-nullable (matching what the dependent side's own + `.HasForeignKey(...)` chain already declares) - **without** repeating `.HasForeignKey(...)` + itself, that's declared once, on the dependent side. **Repeat the same `.OnDelete(...)` call + from the dependent side's chain here too** - declaring delete behavior from both ends, + matching, is what makes it visible when scanning *this* entity's mapping file alone, the same + reasoning as declaring the relationship itself from both ends. EF Core matches the two + configurations by navigation pairing and merges them as the same relationship, so writing it + from both ends is safe as long as they agree. **No comment needed** for the relationship/FK + itself - the `.HasMany(...).WithOne(...)` call already says everything a reader needs; a + comment repeating "FK owned/declared in the other file" for every single one of these is + noise, not information. The `.OnDelete(...)` restatement is the one thing actually worth + duplicating, not narrating. + - **A navigation with no corresponding FK/relationship anywhere** (the model doesn't actually + support what the property implies): don't let it fall through to an accidental EF-invented + shadow relationship. Flag it to the user and ask what it should be - don't guess a + relationship that isn't in the model. If the user says to leave the property in place without + resolving it yet, mark it `.Ignore(x => x.Y)` explicitly (with a comment saying why) rather + than leaving it for EF's convention to silently invent something. + - **A unique constraint** (a property, or a combination of properties, that must be unique): + `.HasIndex(x => x.Y).IsUnique()` (or `.HasIndex(x => new { x.A, x.B }).IsUnique()` for a + composite key) - explicit, never left for a `[Key]`-adjacent attribute or assumed convention + to imply. Ask the user which properties (if any) need this if it isn't already obvious from + the request (e.g. "domain must be unique across all tenants"). + - **Many-to-many relationships are modeled as an explicit join entity, never + `.HasMany(...).WithMany(...)`.** EF Core's implicit many-to-many (a hidden join table with no + entity of its own) means there's no place to hang extra columns later (an assignment + timestamp, a role on the relationship, etc.) without a breaking schema change, and no explicit + mapping file to read the relationship's actual shape from. Model it as its own entity (e.g. + `Product`/`Tag` → a real `ProductTag` entity with `ProductId`/`TagId` FKs) with its own File + 1/File 2 pair - a normal one-to-many-to-one shape from each side, not a special case - even + when the join entity currently has no columns beyond the two FKs. +- No registration step needed - Nano auto-discovers mappings via + `ModelBuilderExtensions.MapEntities` at startup. +- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` + from the app project directory (only do this if the user asked you to, or if the project's + existing workflow clearly expects a migration per entity - check `Migrations/` for + precedent first). + +## File 3 - Query criteria (API/Web only - skip for Console) + +Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or +`Criterias/QueryCriteria.cs` in the single-project layout. + +```csharp +public class QueryCriteria : BaseQueryCriteria +{ + public virtual string? Name { get; set; } + + public override IList GetExpressions() + { + var expressions = base.GetExpressions(); + + var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); + + if (!string.IsNullOrEmpty(this.Name)) + { + expression + .StartsWith("Name", this.Name); + } + + expressions + .Add(expression); + + return expressions; + } +} +``` + +- Only add filter properties for fields that make sense to search/filter by - don't + mechanically add one filter per scalar property on the entity. +- Every filter property must be `virtual` and nullable. +- Use the `CriteriaExpression` builder methods appropriate to each property's type + (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) + - check the project's other query criteria classes for the operations actually available, + don't guess. + +## File 4 - Controller (API/Web only - skip for Console) + +Location: `Controllers/sController.cs` in the main app project. + +```csharp +public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) + : BaseEntityController<, QueryCriteria>(logger, repository, eventing) +{ + // Custom actions, if any +} +``` + +- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a + literal `s` appended, then `Controller` (e.g. `Product` → `ProductsController`, + `Country` → `CountrysController` - note this is naive `+s` pluralization, not proper + English plural rules; Nano derives the route segment from the class name). Do not + "correct" irregular plurals. +- Check whether the project actually registers an eventing provider (look for + `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` + parameter and the corresponding base-constructor argument - an unused optional eventing + dependency is harmless, but match what sibling controllers in the same project actually do. +- If the identity type isn't `Guid` (per step 5), the controller generic list needs the + identity type too: `BaseEntityController<, , QueryCriteria>`. +- **`BaseEntityController<, QueryCriteria>` (full CRUD) is the default for every + entity, with no exceptions other than `[Subscribe]`.** Having custom actions on the same + controller - even ones that overlap in intent with a generic CRUD action - is not on its own a + reason to narrow the tier. A colliding route is a defect to flag (see below), not a signal to + remove generic capability the entity is otherwise entitled to. +- **`[Subscribe]` entities use `BaseEntityCreatableController<, QueryCriteria>`**, + not `BaseEntityController` - per File 1's note, update/delete happen through the Subscribe + mechanism, not this app's HTTP surface, so only Get/Query/Create are exposed here. This is the + *only* case that changes the default tier. +- No manual registration needed - Nano's MVC discovery picks up the controller + automatically from the assembly. + +## After generating + +- Show the user the files generated and where they were placed (two for Console, four for + API/Web); don't silently also modify `Program.cs`, add NuGet packages, or run + `dotnet ef migrations add` unless they ask - scaffolding the entity is the task, not deciding + the rest of the rollout for them. +- If the project has an existing entity with the same shape you can point to as a working + reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/.github/prompts/nano-define-api-client.prompt.md b/.github/prompts/nano-define-api-client.prompt.md deleted file mode 100644 index ee769780..00000000 --- a/.github/prompts/nano-define-api-client.prompt.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -mode: agent -description: Define or extend the Api Client surface a Nano application exposes to other Nano applications - the BaseApiClient subclass and any custom request types, in the owning service's {Name}.Models project. Use when the user asks a service to expose a new client/endpoint to callers, add a custom method to an existing Api Client, or scaffold the client shape for a Nano API or Web application other services will consume. ---- - -# Nano define API client - -Creates or extends the typed HTTP client surface a Nano application exposes to *other* -applications - the counterpart to `nano-add-api-client`, which wires an already-defined client -into a *consumer*. This skill is the owning service's job: deciding what it exposes and how. -Read AGENTS.md's `### Api Clients` section first - it documents the built-in method groups -(`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and the -`{TargetName}.Models/Api/` location convention in full; this skill does not repeat that, only how -to apply it. - -If the user's request is actually about calling this client from some other app, not defining it, -that's `nano-add-api-client`'s job instead - point them there. - -## Before making any change, determine - -1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for - an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If the request is just "add a - method" to an existing one, skip straight to Custom requests/methods below - there's no new - class to create. -2. **Does this application have persistent Identity?** Determines the base class for a *new* - client: `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't - matter to callers), or `BaseIdentityApiClient` (this app has Identity - - unlocks the `.Identity` method group for every consumer). Check this app's own `Data:Identity` - config / `BaseEntityUser`-derived entity. - - **If a client already exists on the plain `BaseApiClient` base and this app has Identity - configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first - defined): that client **must be changed** to derive from `BaseIdentityApiClient` - instead - otherwise none of this app's identity-management endpoints (sign-up, password, - roles, claims, API keys) are reachable through it. Don't leave it on the plain base class - just because "add identity" wasn't the request that triggered this particular change. -3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` - dictionary key on their side must match it exactly) - pick something unambiguous and stable; - renaming it later breaks every consumer's config. -4. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** Per AGENTS.md's - `## Core Principle - Built-In Before Custom`: only add a custom method if a single call can't - compose the existing generic reads/writes, or if query criteria can't express the filter. - Confirm which endpoint(s) this app actually serves before guessing at routes - don't invent a - contract the controller side doesn't have. - -## Client class - -`{ThisApp}.Models/Api/{ClientName}.cs`: - -```csharp -// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit -public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); -``` -```csharp -// Identity-backed - adds the .Identity method group for every consumer -public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); -``` - -Add custom methods only if step 4 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). Give the -method and its doc comment the same one-liner-summary treatment as a scaffolded controller -action - name what it does and, if it exists only because the generic surface couldn't express -it, why. - -## Custom requests (only if step 4 applies) - -`{ThisApp}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. - -**Controller resolution** (per `Nano.App`'s own README): Nano infers the controller segment from -the pluralized `TResponse` type name - this is the standard convention and works fine whenever a -custom request's response genuinely *is* the target Nano entity (e.g. a custom action added to -an existing entity controller that still returns that entity). `this.Controller` must be set -explicitly in the constructor only in the two cases where that inference can't land correctly: -- **No response at all** (`InvokeAsync`, no `TResponse`) - there's no type to infer - from. -- **The route doesn't align with `TResponse`'s pluralized name** - either because `TResponse` is - a bespoke DTO/POCO rather than the entity itself, or because the action lives on a *different* - controller than the one the response type's name would imply (a custom action piggy-backing on - an existing controller rather than getting its own). - -In this solution specifically, most custom requests so far have hit the second case - Public API -and cross-service custom endpoints tend to return bespoke response shapes, or attach to a -controller that doesn't match the response's name (see `GetTenantDomainRequest`: its response is -the `TenantDomain` entity, but the action lives on `TenantsController`, not a dedicated -`TenantDomainsController`) - so check this deliberately rather than assuming inference works. - -**Define the route segment as a constant** in a `Consts` class inside `{ThisApp}.Models` and -reference it from both this request's action attribute and the corresponding controller's -`[Route(...)]` on the app side - per AGENTS.md, nothing else keeps the two sides in sync, and -Nano's own built-in requests avoid drift exactly this way. If the controller action this request -targets doesn't exist yet, say so explicitly - defining the client side of a contract the server -side doesn't implement yet leaves callers with a 404, not a working endpoint. - -**Caller-context fields (tenant id, user id, etc.).** Per AGENTS.md's Authentication forwarding -caveat: if this request's target logic needs a piece of the *caller's* identity, add it as an -explicit property on the request, populated by the calling application from its own JWT - don't -design this request to assume the target controller will re-derive it from the forwarded token -instead. Note in the doc comment which claim the caller is expected to supply and why. - -**Anonymous endpoints.** If this request is meant to be called before a caller has a JWT (e.g. -during another app's own login flow), note in the request's doc comment that the corresponding -controller action must be `[AllowAnonymous]` - the client side can't enforce that, only document -the expectation for whoever implements the controller action. - -## After making the change - -- Show the user every file touched/created, and confirm which project they live in (this app's - own `.Models`, not a consumer's). -- List exactly what's now exposed - the client class name and every custom method added - since - this is the contract other applications will start building against. -- If a custom request's target controller action doesn't exist yet on this app, say so - explicitly rather than leaving an unimplemented contract unmentioned. -- If the user's actual goal was consuming this (or another) client from a different application, - point them at `nano-add-api-client` instead - this skill only defines the surface, it doesn't - wire anything into a caller. diff --git a/.github/prompts/nano-remove-api-client-configuration.prompt.md b/.github/prompts/nano-remove-api-client-configuration.prompt.md new file mode 100644 index 00000000..2bf5d383 --- /dev/null +++ b/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -0,0 +1,69 @@ +--- +mode: agent +description: Stop consuming a Nano Api Client from this application - removes the App:Apis configuration entry and the client's injection site, without touching the client's definition in the owning service. Use when the user asks to remove a call to another Nano service/API, stop consuming an internal service, or drop an API client from this app's Public API composition in a Nano API, Web, or Console application. +--- + +# Nano remove API client configuration + +Removes this application's *consumption* of a Nano Api Client - the `App:Apis` config entry and +the injection site - without touching the client's definition in the owning service's `.Models` +project. The counterpart to `nano-add-api-client-configuration`. Read that skill first - this one +undoes exactly what it adds, and nothing more. + +If the actual goal is to delete the client's definition entirely (so *no* application can consume +it anymore), that's `nano-remove-api-client`'s job instead, on the owning service's side - this +skill never deletes a `.Models` project's client class, since that class may still be consumed by +other applications this skill has no visibility into. + +## Before making any change, determine + +1. **Is the `App:Apis` entry for this client actually present in this app?** Check the base + `appsettings.json` for `App:Apis:{ClientClassName}`. If none, say so and stop. +2. **What depends on it in this app?** Search for the client class used as a constructor + parameter (controller or worker) in *this* app only. This isn't a startup-crash risk - the + client itself has no required-service semantics beyond normal C# compilation - but removing + the config while something still injects the class simply **won't compile** (or, if the class + still resolves some other way, silently stops working). Find every injection site in this app + first. +3. **Is anything else in this app still using the target's `.Models` project?** Once every + injection site from step 2 is gone, check whether this app's `.csproj` reference to the + target's `.Models` project (`ProjectReference` or `PackageReference` - see + `nano-add-api-client-configuration`'s note that private-feed `PackageReference` is the more common + real-world case) has any other reason to exist - a directly-referenced entity/DTO type, + another client targeting the same package, etc. If nothing else uses it, the reference is + safe to remove too; if something does, leave it and say so explicitly rather than guessing. + +## appsettings.json (this app) + +Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its +`LogInRoot`/other overrides from `appsettings.Development.json`, if present. + +**`LogInRoot`'s Staging/Production cleanup is a `deployment.yaml` env-entry removal, not a +secret deletion.** Per `nano-add-api-client-configuration`'s design, this app never creates its own secret for +`LogInRoot` - it only maps into the *target's* existing `auth-root-login-secret` via a +`secretKeyRef`. So there's no Kubernetes secret or GitHub secret on this app's side to delete; +just remove the `App__Apis__{ClientClassName}__LogInRoot__Username`/`Password` `secretKeyRef` +entries from `.kubernetes/deployment.yaml`. The target's `auth-root-login-secret` itself is +unaffected - it's shared/owned by the target app, not this one. + +## Injection sites (this app) + +Remove the constructor parameter and field from every controller/worker in this app that took +this client, per step 2 - this is a compile-breaking change if left in place after the config is +gone. + +## .csproj reference (this app) + +If step 3 found nothing else in this app uses the target's `.Models` project, remove the +`ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it +and say so. + +## After making the change + +- Show the user every file touched in this app, including the `.csproj` reference if it was + removed (or why it was kept, per step 3). +- Note explicitly that the client's own definition in the owning service's `.Models` project was + **not** touched - other applications may still consume it. If the user's actual intent was to + delete the definition entirely, point them at `nano-remove-api-client` next. +- If step 1 or 2 stopped the skill early (no entry present, or unresolved injection sites), that's + the whole response - don't leave broken constructor parameters behind. diff --git a/.github/prompts/nano-remove-custom-endpoint.prompt.md b/.github/prompts/nano-remove-custom-endpoint.prompt.md new file mode 100644 index 00000000..c52afa7f --- /dev/null +++ b/.github/prompts/nano-remove-custom-endpoint.prompt.md @@ -0,0 +1,196 @@ +--- +mode: agent +description: Remove a custom, non-CRUD HTTP endpoint - two genuinely different shapes depending on the controller. A Public API endpoint's removal is just the controller action, its DTOs, and possibly a now-unused Api Client injection. An internal-service endpoint's removal is the controller action plus the paired Api Client custom request/method that called it, removed together since they're one contract. Use when the user asks to remove, delete, or drop a one-off custom action/endpoint from a Nano API or Web application. +--- + +# Nano remove custom endpoint + +Removes a single custom HTTP action generated by `nano-add-custom-endpoint`. Read that skill +first; this one undoes exactly what it adds, following the same Public-API/internal-service split +and the same "Public API," not "gateway," terminology (see that skill's terminology note). + +## Before removing anything, determine + +1. **Which action, on which controller?** Confirm the exact method name and controller - "remove + the endpoint" alone isn't enough if the controller has several custom actions. +2. **Public API or internal-service controller?** Same distinction the scaffold skill makes - + check step 2 below for which path applies before doing anything else. +3. **Endpoint shape / naming** - confirm you have the actual file locations (which project each + piece lives in) rather than assuming the default convention was followed. +4. **Is this actually a redundant custom endpoint, not just an unused one?** Four distinct kinds + of "redundant," each worth naming explicitly rather than just deleting: + - The response is now expressible via generic `.Entity` + `[Include]` - see **Converting to + generic + Include** below instead of removing it outright. + - A sibling custom endpoint already returns everything this one does (e.g. a single-item lookup + that duplicates a list endpoint's already-populated shape - see + `nano-add-custom-endpoint`'s "check whether a sibling list endpoint already makes it + redundant" note). This is a plain removal, not a migration, but say plainly in the summary + which sibling endpoint now covers the removed one's job, so callers know where to go instead. + - The custom action's whole job was "the generic write plus an invariant" - see **Converting to + a generic-action override** below instead of removing it outright. + - Its Response DTO is now a near-duplicate of a sibling DTO because something it existed to + route around (an indirection layer, a since-removed entity) went away - see + `nano-add-custom-endpoint`'s "check for an existing sibling DTO" note. Removing the action and + switching remaining callers to the sibling DTO is a plain removal too, worth naming the same + way. +5. **Is a step in this endpoint's logic redundant because of DB-level cascade, not because the + endpoint itself is unneeded?** Before removing or "simplifying" a composed delete/update flow, + check whether an explicit child delete/cleanup call is actually doing something a required EF + Core relationship's default `Cascade` behavior already does for free (see + `nano-add-custom-endpoint`'s cascade note in step 1) - if so, that specific call is what's + redundant, not necessarily the endpoint around it. Confirm the parent isn't soft-deletable + (`IEntitySoftDeletable`) first - cascade doesn't apply to a soft delete, since it's an `UPDATE`, + not a real `DELETE`. + +--- + +## Public API path + +1. **Is the injected Api Client still needed by another action on this controller?** Check every + other action before deciding whether to also drop the constructor parameter/field - don't + remove a dependency other actions still use, and don't leave an unused-parameter compiler + error (`CS9113` under this solution's `TreatWarningsAsErrors`) behind either. +2. **Are the Request/Response DTOs used anywhere else?** Check for other actions in the same + project referencing the same `Request`/`Response` classes before deleting them. + +### Files/code to remove + +- The controller action itself (method, its `[Http*]`/`[Route]`/`[ProducesResponseType]` + attributes, its doc comment). +- `Requests//Request.cs` and `Responses//Response.cs` - only if + nothing else uses them. +- If this was the controller's only custom action and it has no generic CRUD/entity backing (a + bare `BaseController` created solely for this one endpoint), ask the user whether the now-empty + controller should go too - an empty controller isn't broken, just dead weight, so this is a + judgment call, not a mandatory cleanup step. + +### Api Client injection + +If nothing else on this controller uses the injected Api Client, remove the constructor +parameter/field. Don't remove the client's own definition or its `App:Apis` config entry as part +of this - that's `nano-remove-api-client-configuration`'s job (this app's consumption) or +`nano-remove-api-client`'s (the owning service's definition), separate decisions the user +should make explicitly rather than have cascade from removing one endpoint. + +--- + +## Internal service path + +This action **is** one half of a contract another application may call - its removal has to +account for the *paired* Api Client custom request/method the same way `nano-add-custom-endpoint` +scaffolds them together, not just the controller side. + +1. **Is this action's route what another application's Api Client request targets?** This is the + real risk here: removing it breaks that caller. This skill has no visibility into other repos' + consumers - say so plainly rather than implying nothing calls it. +2. **Was a route constant shared** between this controller's `[Route(...)]` and the paired Api + Client request's action attribute (per the scaffold skill's route-constant-sharing step)? + Removing that constant is the sharpest version of the risk above - a guaranteed compile break + for the request class that referenced it, not just a stale endpoint. Confirm before removing. +3. **Is the shared body model used anywhere else?** Since the scaffold skill defines one model + class used by both the controller's `[FromBody]` parameter and the Api Client request's + `[Body]` property, check nothing else references it before deleting it. + +### Files/code to remove + +- The controller action itself. +- The paired Api Client method on this app's own client class (`{ThisApp}.Models/Api/{ClientName}.cs`). +- The Api Client request class (`{ThisApp}.Models/Api/Requests/{Name}Request.cs`). +- The shared body model and any dedicated response POCO - only if step 3 found nothing else uses + them. +- The route constant in `{Name}.Models/Consts/` - only if step 2 found it existed solely for this + action. + +Remove all of these together, in the same change - this mirrors how they were scaffolded +together; leaving the Api Client method in place while deleting the controller action produces a +client that compiles but 404s the moment anyone calls it, which is worse than removing neither. + +If the user's actual request was narrower - "just remove the Api Client method, keep the +controller action" or vice versa - that's a real but unusual ask; confirm that's really what they +want before doing a partial removal, since it deliberately leaves one side of the contract +without the other. + +--- + +## Converting to generic + Include (instead of removing outright) + +Sometimes the reason to remove a custom endpoint isn't that it's unused - it's that +`nano-add-custom-endpoint`'s step 1 decision procedure (built-in before custom) now says it never +needed to be custom in the first place: the same response is expressible as the target entity plus +`[Include]`-tagged navigations at some `includeDepth`. Handle this as one combined migration, not a +removal followed by an unrelated addition: + +1. **Confirm the shape actually fits.** Walk through `nano-add-custom-endpoint`'s step 1 limits - + no selective `$expand`, and `[Include]` being global rather than per-caller - before assuming + this conversion applies. If either limit bites, this endpoint should stay custom; don't force + the conversion just because the response happens to include some navigations. +2. **Tagging `[Include]` on the entity changes its existing generic surface, not just this one + caller's response.** Every other consumer of that entity's generic endpoints - other internal + services, other Public APIs - becomes able to (and, depending on their own `includeDepth`, + will) eager-load this navigation too, once it's tagged. Say this plainly and confirm with the + user before tagging it, the same way `nano-remove-data-provider` confirms before deleting + mappings other code depends on. +3. **Update the caller to use the generic `.Entity` call directly**, with the right `includeDepth` + for what it actually needs (`0` for a bare entity, higher to reach the newly-tagged + navigation(s)) - see `nano-add-custom-endpoint`'s "don't wrap plain generic calls" note in its + Public API path; this replacement call should not go through a new custom Api Client method + either. +4. **Then remove the custom endpoint's pieces** per the Public-API/Internal-service steps above, + same as any other removal. + +Report this to the user as one combined change: what got tagged `[Include]` and why, which other +consumers now gain visibility into it as a result, and what was removed because the generic call +now covers it. + +--- + +## Converting to a generic-action override (instead of removing outright) + +Sometimes a custom endpoint's whole job was never "a distinct operation" - it was "the generic +write, plus an invariant that must always hold" (validation before create/edit, a linked-entity +side-effect, a reference-count guard before a delete or a create). Per +`nano-add-custom-endpoint`'s "Overriding a generic CRUD action instead of a new endpoint," that +belongs as an override on the entity's own `BaseEntityController<...>` method(s), not a separate +route. Handle this as one combined migration: + +1. **Confirm the endpoint doesn't need caller-context the override's fixed signature can't carry.** + An override of `EditAsync(TEntity entity, CancellationToken)`/`DeleteAsync(Guid id, + CancellationToken)` can't gain an extra parameter the removed custom action might have taken + (e.g. a `tenantId` used for ownership scoping). If the removed endpoint did real + caller-identity-based authorization - not just validation derivable from the entity/data itself + - that check has to move to (or stay at) the calling Public API as its own pre-check (e.g. a + scoped `QueryFirst` before calling the generic write), not disappear. Say this plainly rather + than silently dropping the check. +2. **Identify every generic write variant the invariant must survive.** If callers can reach + `CreateAsync`/`CreateAndGetAsync`/`CreateOrGetAsync`, or `EditAsync`/`EditAndGetAsync`, or + `DeleteAsync`/`DeleteManyAsync` - override each variant that's actually reachable, not just the + one the endpoint being removed happened to mirror. Missing a sibling variant reopens exactly the + gap the custom endpoint existed to close. A guard derived from another entity's existence (e.g. + "immutable once referenced") typically belongs on both the create and the delete variants, not + just whichever one the removed endpoint originally covered. +3. **Move the logic, adjusting for the override's constraints**: throw + `BadRequestException`/`NotFoundException` rather than returning bare `IActionResult`s for + anything that must propagate correctly through an Api Client (see `nano-add-custom-endpoint`'s + note on this); use `entity.Id` directly in a create override rather than the base call's result, + since `BaseEntity`'s constructor already assigned it; reconcile any collection navigation via + explicit repository calls, since generic Edit still won't do it for you. +4. **Then remove the custom endpoint's pieces** per the Internal service path steps above, same as + any other removal - including the paired Api Client method/request the caller used, once callers + are updated to hit the generic route directly instead. + +Report this to the user as one combined change: which generic action(s) now carry the invariant, +which caller-context check (if any) had to move to the Public API instead, and what was removed +because the override now covers it. + +--- + +## After making the change + +- Show the user every file/method touched or deleted, grouped by concern, and which path was + taken. +- **Public API path**: if the Api Client injection was left in place because another action still + needs it, say so rather than leaving it unexplained. +- **Internal service path**: restate the cross-application risk from step 1 - this skill can only + confirm nothing *local* still calls it, not that nothing anywhere does. If step 2 found a + shared route constant, restate explicitly that removing it is a guaranteed break for whatever + other application's request referenced it, if any. diff --git a/.github/prompts/nano-remove-entity.prompt.md b/.github/prompts/nano-remove-entity.prompt.md new file mode 100644 index 00000000..30a10a98 --- /dev/null +++ b/.github/prompts/nano-remove-entity.prompt.md @@ -0,0 +1,96 @@ +--- +mode: agent +description: Remove a Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. Use when the user asks to remove, delete, or drop a specific entity/resource from a Nano-based application, as a standalone request (not as a side effect of removing a whole Data provider or Identity - see nano-remove-data-provider/nano-remove-identity for those). +--- + +# Nano remove entity + +Removes everything `nano-add-entity` generates for one entity - data model, EF Core mapping, +query criteria, and CRUD controller - as a standalone request. Read that skill first; this one +undoes exactly what it adds, file for file, in reverse. + +**Not the same job as `nano-remove-data-provider`/`nano-remove-identity`.** Those remove an entity +only as a side effect of a bigger structural change (no Data provider left to persist it, or +Identity being torn out). This skill is for "just delete this one entity" - a genuine standalone +request that doesn't imply anything else in the app is changing. + +## Before removing anything, determine + +1. **Which entity, and where does it live?** Confirm the exact class name and check the project + layout (split `.Models` project vs single-project, per `nano-add-entity`'s own layout + rule) to know where each file actually is. +2. **What else in this repo references it?** Two distinct risks, not one: + - **Other entities' relationships.** Search every other entity's mapping for a + `.HasOne(...)`/`.HasMany(...)` pointing at this entity (per `nano-add-entity`'s + both-ends-explicit mapping convention, the reference could be declared on *either* side). + Removing the entity without also removing or reworking the other side's relationship + configuration breaks that mapping - an `EntityTypeBuilder` call referencing a type that no + longer exists doesn't compile. Find every one before touching anything, and confirm with the + user how each should be resolved (drop the relationship entirely, or point it at something + else) rather than guessing. + - **Is this entity itself a many-to-many join entity, or does removing it orphan one?** Per + `nano-add-entity`'s convention, a many-to-many relationship is modeled as its own join entity + (e.g. `ProductTag`), not EF's implicit join table - so removing one side of that relationship + (`Product` or `Tag`) leaves the join entity (`ProductTag`) with a dangling FK to a type that + no longer exists, the same compile break as any other orphaned relationship. Remove the join + entity too (its own File 1/File 2 pair) as part of the same change, not as an afterthought + once the build breaks. + - **Custom controller actions or Api Client custom requests targeting it.** Per + `nano-add-custom-endpoint`'s internal-service path, an entity's controller may carry custom + actions backing another application's Api Client methods, alongside its generic CRUD surface. + Deleting the controller without accounting for those leaves that Api Client calling a route + that no longer exists - the same class of cross-application risk `nano-remove-api-client` + flags for a client definition, just via the generic/custom controller surface instead of a + `BaseApiClient` subclass. List every matching request found and confirm with the user before + proceeding. +3. **Is this entity consumed outside this repo at all?** If `{ThisApp}.Models` is published (NuGet + or private feed) and this entity's type, query criteria, or controller-backed routes are part + of that public surface, other applications entirely outside this repo may reference it - + something this skill has no visibility into. Say this plainly rather than implying "nothing + references it" just because nothing in *this* repo does. +4. **Is it `[Publish]` or `[Subscribe]`?** (AGENTS.md's `### Entity Events`) The two sides carry + very different risk: + - **`[Publish]`** - this app is the source of truth other applications replicate from. Removing + it here stops every downstream `[Subscribe]`r from ever receiving another `Added`/`Modified`/ + `Deleted` event for it - their local replicas silently go stale, not error out. This is the + same class of cross-application risk `nano-remove-api-client` flags for a client definition, + and just as invisible: this skill has no way to see which other applications subscribe to + this `TypeName`, in this repo or (especially) outside it. Say that plainly and confirm with + the user before removing a `[Publish]`d entity - don't imply "nothing subscribes to this" + just because nothing does *locally*. + - **`[Subscribe]`** - the opposite, low-risk case: this is a local replica kept in sync from + another application's source entity. Removing it here only stops *this* app from keeping a + local copy; it has no effect on the publishing app's real entity or any other subscriber. + Still worth confirming the user understands the distinction, especially if the request was + phrased as "delete the entity" without specifying which side - but this is a much smaller + decision than removing the `[Publish]` side. +5. **Has this entity ever actually persisted data?** Check `Migrations/` for one that created its + table. If none exists, dropping the mapping/model is the whole job. If one does, removing the + mapping without a corresponding migration leaves the table behind in the database, orphaned - + ask whether the user wants a new migration generated to drop it (`dotnet ef migrations add + `, same "only if asked, or if the project's workflow clearly expects one per entity" + rule `nano-add-entity` uses), since this is a real, generally irreversible data-loss + action on top of removing the code, not something to do unprompted. + +## Files to delete + +- `Controllers/sController.cs` (API/Web only) - check step 2's second risk first. +- `Criterias/QueryCriteria.cs` (API/Web only, whichever project per the layout). +- `Data/Mappings/Mapping.cs` (main app project, always). +- `Data/.cs` (whichever project per the layout) - check step 2's first risk first; don't + delete this before every other entity's relationship to it has been resolved, or you'll be + fixing the same compile break twice. + +## After making the change + +- Show the user every file deleted, and every other file touched to resolve step 2's + relationship/collision risks (the other side of a relationship, or a flagged Api Client + consumer) - not just this entity's own four files. +- Restate step 3's cross-repo caveat if `{ThisApp}.Models` is published - this skill can only + confirm nothing *local* still depends on the entity, not that nothing anywhere does. +- Restate step 5's outcome - whether a migration was generated to actually drop the table, or + whether that was deliberately left for the user to do separately (in which case the table + stays behind until they do). +- If step 2 surfaced unresolved relationships or consumers the user hasn't decided how to handle, + that's the whole response - don't delete the entity out from under a mapping or controller that + still expects it to exist. diff --git a/.github/prompts/nano-scaffold-entity.prompt.md b/.github/prompts/nano-scaffold-entity.prompt.md deleted file mode 100644 index 7d253110..00000000 --- a/.github/prompts/nano-scaffold-entity.prompt.md +++ /dev/null @@ -1,158 +0,0 @@ ---- -mode: agent -description: Scaffold a new Nano.Library entity end-to-end - data model, EF Core mapping, query criteria, and CRUD controller - following Nano framework conventions. ---- -# Nano entity scaffold - -Generate the four files Nano needs for a new CRUD-capable entity: data model, EF Core -mapping, query criteria, and controller. Read `AGENTS.md` in the target repo root first if -present - it documents the exact base classes and gotchas for that specific solution; these -instructions describe the general Nano.Library conventions and defer to a project's own -AGENTS.md on any conflict. - -## Before generating anything, determine - -1. **Entity name and properties.** Ask the user if not already given in the request - need - at minimum the entity name (singular, PascalCase, e.g. `Product`) and its scalar - properties (name + type). Don't invent business fields that weren't asked for. -2. **Project layout.** Look for a `.Models` project alongside the main app - project (check the `.sln` or list sibling folders). - - **Split layout** (a `.Models` project exists, e.g. `Svc.Accounts.Models`): entity - model and query criteria go in the `.Models` project (they're part of the API client - contract other services consume); the mapping and controller go in the main app - project. - - **Single-project layout** (no `.Models` project, e.g. most Nano.Lessons): all four - files go in the one app project. -3. **Identity type.** Check the `DbContext`/`DbContextFactory` and other entities in the - project for a `TIdentity` type parameter (e.g. `BaseEntity`). If every existing - entity just uses plain `BaseEntity` (implicit `Guid`), match that. Never introduce a - non-`Guid` identity unless the project already uses one consistently - it's a - cross-cutting decision (affects the entity, mapping, controller, repository calls, - and API client), not something to add on a whim for one entity. -4. **Existing conventions.** Skim one existing entity/mapping/controller triplet in the - project (if any exist) for property style, nullable-reference usage, and namespace - layout, and match it. - -## File 1 - Data model - -Location: `Data/.cs` in the split layout's `.Models` project, or `Data/.cs` -in the single-project layout. - -```csharp -public class : BaseEntity -{ - public string Name { get; set; } = null!; - // ...other scalar properties as requested -} -``` - -- Derive from `BaseEntity` (Guid identity) or `BaseEntity` - match the project's - existing convention (see step 3 above). -- For restricted CRUD (e.g. read-only, or no delete), derive instead from - `BaseEntityReadOnly`, `BaseEntityCreatable`, `BaseEntityUpdatable`, - `BaseEntityCreatableAndUpdatable`, or `BaseEntityDeletable` - ask the user if the request - implies one of these rather than full CRUD. -- Use `required`/`= null!` per the project's existing nullable-reference style, not your own - default. - -## File 2 - Data mapping - -Location: `Data/Mappings/Mapping.cs` in the main app project (always here, even in -the split layout - mappings are EF Core-only, never part of the shared API client models). - -```csharp -public class Mapping : BaseEntityMapping<> -{ - public override void Configure(EntityTypeBuilder<> builder) - { - ArgumentNullException.ThrowIfNull(builder); - - base.Configure(builder); - - builder - .Property(x => x.Name); - } -} -``` - -- **Always call `base.Configure(builder)`** before your own configuration - omitting it - silently breaks inherited Nano behavior (soft delete, audit, etc.). This is the single - most common mistake when writing a mapping by hand. -- No registration step needed - Nano auto-discovers mappings via - `ModelBuilderExtensions.MapEntities` at startup. -- Add a new EF Core migration after this file exists: `dotnet ef migrations add ` - from the app project directory (only do this if the user asked you to, or if the project's - existing workflow clearly expects a migration per entity - check `Migrations/` for - precedent first). - -## File 3 - Query criteria - -Location: `Criterias/QueryCriteria.cs` in the split layout's `.Models` project, or -`Criterias/QueryCriteria.cs` in the single-project layout. - -```csharp -public class QueryCriteria : BaseQueryCriteria -{ - public virtual string? Name { get; set; } - - public override IList GetExpressions() - { - var expressions = base.GetExpressions(); - - var expression = expressions.FirstOrDefault() ?? new CriteriaExpression(); - - if (!string.IsNullOrEmpty(this.Name)) - { - expression - .StartsWith("Name", this.Name); - } - - expressions - .Add(expression); - - return expressions; - } -} -``` - -- Only add filter properties for fields that make sense to search/filter by - don't - mechanically add one filter per scalar property on the entity. -- Every filter property must be `virtual` and nullable. -- Use the `CriteriaExpression` builder methods appropriate to each property's type - (`StartsWith`/`Contains` for strings, `EqualTo`/`GreaterThan`/etc. for numerics and dates) - - check the project's other query criteria classes for the operations actually available, - don't guess. - -## File 4 - Controller - -Location: `Controllers/sController.cs` in the main app project. - -```csharp -public class sController(ILogger<sController> logger, IRepository repository, IEventing? eventing) - : BaseEntityController<, QueryCriteria>(logger, repository, eventing) -{ - // Custom actions, if any -} -``` - -- **Naming is load-bearing, not cosmetic**: the class name must be the entity name with a - literal `s` appended, then `Controller` (e.g. `Product` -> `ProductsController`, - `Country` -> `CountrysController` - note this is naive `+s` pluralization, not proper - English plural rules; Nano derives the route segment from the class name). Do not - "correct" irregular plurals. -- Check whether the project actually registers an eventing provider (look for - `AddNanoEventing<...>()` in `Program.cs`). If it doesn't, drop the `IEventing? eventing` - parameter and the corresponding base-constructor argument - an unused optional eventing - dependency is harmless, but match what sibling controllers in the same project actually do. -- If the identity type isn't `Guid` (per step 3), the controller generic list needs the - identity type too: `BaseEntityController<, , QueryCriteria>`. -- No manual registration needed - Nano's MVC discovery picks up the controller - automatically from the assembly. - -## After generating - -- Show the user the four files and where they were placed; don't silently also modify - `Program.cs`, add NuGet packages, or run `dotnet ef migrations add` unless they ask - - scaffolding the entity is the task, not deciding the rest of the rollout for them. -- If the project has an existing entity with the same shape you can point to as a working - reference, mention it - it's the fastest way for the user to sanity-check the result. diff --git a/.github/prompts/nano-undefine-api-client.prompt.md b/.github/prompts/nano-undefine-api-client.prompt.md deleted file mode 100644 index 5a33151c..00000000 --- a/.github/prompts/nano-undefine-api-client.prompt.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -mode: agent -description: Delete an Api Client surface (or a custom method on it) that a Nano application exposes to other applications - the BaseApiClient subclass and its custom request/response types, from the owning service's {Name}.Models project. Use when the user asks to stop exposing an endpoint/client to other services, remove a custom method from an Api Client, or delete a client's definition entirely from a Nano API, Web, or Console application. ---- - -# Nano undefine API client - -Deletes an Api Client's definition - the `BaseApiClient` subclass and/or its custom request -types - from the *owning* service's `{Name}.Models` project. The counterpart to -`nano-define-api-client`. This is a different, more consequential operation than a single -consumer dropping the client: every application currently consuming this class loses it. - -If the actual goal is just "this app should stop calling that service," not "delete the client -definition entirely," that's `nano-remove-api-client`'s job instead (the *consumer* side) - point -the user there; don't delete a shared definition to satisfy one consumer's request. - -## Before making any change, determine - -1. **Is this a full class removal, or just one custom method?** "Remove the `GetByEmailAsync` - method from `MyApi`" is much narrower than "delete `MyApi` entirely" - confirm scope before - touching anything. -2. **Who else consumes this class?** Search every application that references this - `{Name}.Models` project (`ProjectReference` in a monorepo, or every consumer of the published - NuGet if it's cross-repo) for an `App:Apis` entry matching this class name, or the class - injected into a controller/worker. **Every one of those breaks** - either a compile error (if - the class itself is deleted) or a dead/misconfigured `App:Apis` entry (if just a method - consumers called is removed). List every affected consumer and confirm with the user before - proceeding - this is not a decision to make unilaterally on the owning service's behalf. -3. **Custom request/response types** - if a custom method is being removed and its request/ - response types (`{Name}Request.cs` under `Api/Requests/`, any dedicated response POCO) exist - solely to support it, they come out too. Check nothing else references them first. - -## Client class and custom methods - -If removing the whole class: delete `{ThisApp}.Models/Api/{ClientName}.cs` and every -request/response type that existed solely to support it. - -If removing one custom method: delete just that method from the class, plus its dedicated -request/response types (per step 3) - leave the rest of the class, and any generic -`.Entity`/`.Auth`/`.Audit`/`.Identity` usage, untouched. - -## Route constants - -If a `Consts` class constant (per `nano-define-api-client`'s "define the route segment as a -constant" step) existed solely for the removed request's route, remove it too - otherwise it's -a dangling reference to a route nothing serves anymore. - -## After making the change - -- Show the user every file touched/deleted in this app's `.Models` project. -- Restate every consuming application identified in step 2 as still needing its own cleanup - - this skill only removes the definition; each consumer's own `App:Apis` entry and injection site - is `nano-remove-api-client`'s job, on that consumer's side, once they've been told. -- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop - here and let them decide how to proceed with each one, rather than deleting out from under - them. diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 9a2d69bc..1037b7b3 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -8,7 +8,7 @@ on: - master env: APP_NAME: Nano.Library - VERSION: 10.0.12 + VERSION: 10.0.13 jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/README.md b/README.md index c8456396..07dc51ab 100644 --- a/README.md +++ b/README.md @@ -183,10 +183,11 @@ of your own Nano-based application's repository. Alongside `AGENTS.md`, this repository also includes a **`.claude`** folder with reusable **[Agent Skills](https://code.claude.com/docs/en/skills)** for common, repeatable Nano development tasks: `AGENTS.md` is reference knowledge an agent reads, a skill is a runnable capability an agent invokes to actually perform a task the same way every time. More skills may be added over time as new repeatable workflows are identified. Like `AGENTS.md`, skills are discovered locally and aren't inherited from a NuGet dependency; copy the `.claude` folder into -the root of your own Nano-based application's repository to use them. +the root of your own Nano-based application's repository to use them. If you want discoverability, point to `.claude/skills/` and `.github/prompts/` directly rather than duplicating +the list in prose. GitHub Copilot is supported the same way: a **`.github/copilot-instructions.md`** provides always-on repository context (read automatically by Copilot in VS Code, Visual Studio, JetBrains, -and github.com, no setup needed), and a **`.github/prompts`** folder provides the Copilot equivalent of the entity-scaffolding skill, invokable as `/nano-scaffold-entity` in Copilot Chat. +and github.com, no setup needed), and a **`.github/prompts`** folder provides a Copilot prompt file for every skill, invokable the same way, e.g. `/nano-add-entity` in Copilot Chat. A **`.vscode/settings.json`** enables prompt-file discovery in VS Code out of the box. Like `AGENTS.md` and `.claude`, these are discovered locally: copy `.github/copilot-instructions.md`, `.github/prompts`, and `.vscode/settings.json` into the root of your own Nano-based application's repository to use them. diff --git a/sync-agents-md.ps1 b/sync-agents-md.ps1 index 70f79e29..4cace57d 100644 --- a/sync-agents-md.ps1 +++ b/sync-agents-md.ps1 @@ -3,7 +3,10 @@ Copies Nano.Library's AGENTS.md, .claude folder (Claude Code skills), .github/prompts folder (Copilot prompt files), .github/copilot-instructions.md (Copilot always-on context), and .vscode/settings.json (enables prompt file discovery in VS Code) into the relevant subfolders of - the sibling Nano.Templates, Nano.Lessons, and .vsTemplates repos, overwriting. + the sibling Nano.Templates, Nano.Lessons, and .vsTemplates repos, overwriting. The destination + .claude and .github/prompts folders are deleted and recreated from source on every run, so a + skill or prompt renamed/removed in Nano.Library is also removed from every synced folder, not + just left stale alongside the new ones. .DESCRIPTION Run this from within Nano.Library itself. It expects Nano.Templates, Nano.Lessons, and .vsTemplates @@ -66,6 +69,9 @@ function Copy-ToQualifyingFolders { if (Test-Path $claudeSourcePath) { $claudeDestinationPath = Join-Path $folder.FullName ".claude" + if (Test-Path $claudeDestinationPath) { + Remove-Item -Path $claudeDestinationPath -Recurse -Force + } New-Item -Path $claudeDestinationPath -ItemType Directory -Force | Out-Null Copy-Item -Path (Join-Path $claudeSourcePath "*") -Destination $claudeDestinationPath -Recurse -Force Write-Output ("Copied .claude to " + $claudeDestinationPath) @@ -73,6 +79,9 @@ function Copy-ToQualifyingFolders { if (Test-Path $promptsSourcePath) { $promptsDestinationPath = Join-Path $folder.FullName ".github\prompts" + if (Test-Path $promptsDestinationPath) { + Remove-Item -Path $promptsDestinationPath -Recurse -Force + } New-Item -Path $promptsDestinationPath -ItemType Directory -Force | Out-Null Copy-Item -Path (Join-Path $promptsSourcePath "*") -Destination $promptsDestinationPath -Recurse -Force Write-Output ("Copied .github/prompts to " + $promptsDestinationPath) From c198930094664222a53b513bed04346c3c6a4af1 Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 16 Sep 2026 12:58:08 +0200 Subject: [PATCH 02/10] Added httpContextExntesion.GetJwtClaimValues() --- .../Extensions/HttpContextExtensions.cs | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs b/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs index 65595900..1d4c81f5 100644 --- a/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs +++ b/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs @@ -1,7 +1,9 @@ -using System; -using System.IdentityModel.Tokens.Jwt; using Microsoft.AspNetCore.Http; using Nano.Data.Abstractions.Identity.Consts; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; namespace Nano.Data.Abstractions.Identity.Extensions; @@ -140,4 +142,34 @@ public static class HttpContextExtensions return jwtSecurityTokenHandler .GetClaimValue(jwtToken, claimType); } + + /// + /// Get Jwt Claim Values. + /// + /// The . + /// The claim type. + /// The claim values, or an empty collection if the token or claim isn't present. + public static IEnumerable GetJwtClaimValues(this HttpContext httpContext, string claimType) + { + if (httpContext == null) + throw new ArgumentNullException(nameof(httpContext)); + + var jwtToken = httpContext.GetJwtToken(); + if (jwtToken == null) + { + return []; + } + + var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); + if (!jwtSecurityTokenHandler.CanReadToken(jwtToken)) + { + return []; + } + + return [.. jwtSecurityTokenHandler + .ReadJwtToken(jwtToken) + .Claims + .Where(claim => claim.Type == claimType) + .Select(claim => claim.Value)]; + } } \ No newline at end of file From a59e48f9317847798ecd72ad93d984388bbd4509 Mon Sep 17 00:00:00 2001 From: vivet Date: Thu, 17 Sep 2026 15:17:11 +0200 Subject: [PATCH 03/10] Added prompts and skill for adding Microsoft authentication --- .../nano-add-authentication-jwt/SKILL.md | 14 +- .../SKILL.md | 221 ++++++++++++++++++ .github/copilot-instructions.md | 14 +- ...ano-add-authentication-microsoft.prompt.md | 221 ++++++++++++++++++ AGENTS.md | 17 ++ 5 files changed, 476 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/nano-add-authentication-microsoft/SKILL.md create mode 100644 .github/prompts/nano-add-authentication-microsoft.prompt.md diff --git a/.claude/skills/nano-add-authentication-jwt/SKILL.md b/.claude/skills/nano-add-authentication-jwt/SKILL.md index e4bbdd0f..fe892035 100644 --- a/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -148,10 +148,16 @@ block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, `Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as real secrets, the same class of value as the JWT keys above — `null` in the base file, a real -value only where it's actually safe to have one. AGENTS.md doesn't document an established -Kubernetes-secret/GitHub-secret convention for these specifically (unlike `auth-jwt-secret`/ -`auth-api-key-secret`/`auth-sql-secret`) — don't invent one; ask the user how they want it stored -for Staging/Production rather than assuming a pattern that doesn't exist yet in this codebase. +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** — it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention (see + `Nano.Lessons/Api.Auth.External.Microsoft`). If the request names Microsoft specifically, use + that skill instead of configuring `Jwt.ExternalLogins.Microsoft` by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console — don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. **Custom provider — real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in diff --git a/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/.claude/skills/nano-add-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..b7fb6068 --- /dev/null +++ b/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -0,0 +1,221 @@ +--- +name: nano-add-authentication-microsoft +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table — it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode — if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for +creating their app credentials (created by hand through each provider's own developer console), so +there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production — don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop — see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + either way) — it only changes which endpoint ultimately exposes the login per AGENTS.md's + sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's + concern to scaffold, only worth knowing when telling the user where the login endpoint lives. +3. **Development only, or does this need to actually work in Staging/Production?** If the user only + wants to test locally, do the appsettings step below and stop — skip the CI/Kubernetes sections + entirely rather than wiring up infrastructure nobody asked for yet. +4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for + Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't + guess a port/path. + +## appsettings.json — Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email" ] + } +} +``` + +`appsettings.Development.json` — same shape, still `null`. **Do not hardcode real values here**, +unlike the shared JWT Development key pair — a Microsoft app registration is tied to a real Azure +tenant, not a throwaway pair everyone in the codebase can share. The developer fills these in +locally themselves, after creating their own Entra ID app registration (Azure Portal → Microsoft +Entra ID → App registrations → New registration → Web redirect URI matching whatever client will +call this → Certificates & secrets → new client secret, copied immediately since it's shown once → +note the Application (client) ID and Directory (tenant) ID). No Graph API permission is needed +beyond the default — `openid`/`profile`/`email` only affect what lands in the `id_token`, not access +to any resource. + +`appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section +below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes +secret, the same way `Jwt.PublicKey`/`PrivateKey` are — not present in any config file for those +environments. + +## Staging/Production — self-provisioning, self-rotating CI step + +This is the part that's actually scriptable, unlike Facebook/Google. Add one workflow-level env var: + +```yaml +env: + AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} +``` + +`vars`, not `secrets` — it's just a URL, not sensitive. Ask the user for its value (the real +deployed client's callback URL) rather than defaulting to a placeholder. + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + run: | + $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; + $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + + if (-not $env:AUTH_MICROSOFT_CLIENT_ID) + { + az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + + $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + } + else + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + } + + $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $staleCredentialIds = az ad app credential list --id $env:AUTH_MICROSOFT_CLIENT_ID --query "sort_by(@, &startDateTime)[:-3].keyId" -o tsv; + + foreach ($keyId in ($staleCredentialIds -split "`n" | Where-Object { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV; + echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app up by display name first; creates it only if + missing, otherwise just keeps its redirect URI in sync. `TenantId` needs no separate handling at + all — it's already the workflow's own `$env:AZURE_TENANT_ID` (used for `az login`), so the + Kubernetes secret below reads that directly rather than a Microsoft-specific copy of it. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret — any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working — comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` — this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs — every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AZURE_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) — new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## Reference implementation + +`Nano.Lessons/Api.Auth.External.Microsoft` is this exact setup end-to-end (transient login, no +Identity) — its `.github/workflows/build-and-deploy.yml`, `.kubernetes/auth-microsoft-secret.yaml`, +and `.kubernetes/deployment.yaml` are the working, tested version of everything above. When in +doubt about exact formatting or step ordering, diff against that lesson rather than guessing. + +## After making the change + +- Show the user every file touched, grouped by concern: appsettings per environment, and — if + Staging/Production was in scope — the workflow env var + new step, the new Kubernetes secret file, + the `deployment.yaml` additions, and the `.sln` entry. +- If step 3 found this was Development-only, that's the whole response — don't wire up the CI/ + Kubernetes half unasked. +- Remind the user to create their own Entra ID app registration for Development (the manual steps + above) before testing locally — this skill can't do that part for them, only Staging/Production is + scriptable. +- If they also want Facebook or Google, say plainly that this skill doesn't cover those — configure + `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret + stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5c24ce3c..39722a8f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -46,10 +46,10 @@ by location. `.github/prompts/` holds one `.prompt.md` per Nano task - scaffolding an entity, a custom (non-CRUD) endpoint, or a custom Api Client method; adding/removing a provider (data, storage, eventing, -logging), identity, authentication (JWT and API-key), Azure Managed Identity, an API client (as a -consumer) or its definition (as the owning service), a console worker, a startup task, health -checks, metrics, public exposure, and availability checks. Each is invokable directly in Copilot -Chat as `/`, e.g. `/nano-add-identity` or -`/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request matches -one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md alone -doesn't spell out step-by-step. +logging), identity, authentication (JWT, API-key, and Microsoft external login), Azure Managed +Identity, an API client (as a consumer) or its definition (as the owning service), a console +worker, a startup task, health checks, metrics, public exposure, and availability checks. Each is +invokable directly in Copilot Chat as `/`, e.g. `/nano-add-identity` +or `/nano-remove-storage-provider`. Prefer the matching prompt over improvising when a request +matches one of these tasks - they encode the project-specific sequencing and gotchas AGENTS.md +alone doesn't spell out step-by-step. diff --git a/.github/prompts/nano-add-authentication-microsoft.prompt.md b/.github/prompts/nano-add-authentication-microsoft.prompt.md new file mode 100644 index 00000000..3f19f869 --- /dev/null +++ b/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -0,0 +1,221 @@ +--- +mode: agent +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application - adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application - requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +--- + +# Nano add Microsoft authentication + +Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an +existing Nano API/Web application. Read AGENTS.md's `#### Authentication` section first, especially +the "External login providers" table - it documents the flow type (`AuthCodeFlow`), why `Scopes` +must include `openid`, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup +at all. This skill does not repeat that, only how to apply it. + +**Prerequisite: `Jwt` must already be configured.** `ExternalLogins` is a sub-section of +`Authentication:Jwt`, not a standalone auth mode - if the app has no `Jwt` config or `AuthController` +yet, stop and point the user at `nano-add-authentication-jwt` first (it covers persistent vs. +transient and the `AuthController` itself; this skill only adds the Microsoft-specific piece under +`ExternalLogins`). + +**Facebook/Google are explicitly out of scope for this skill.** They have no CLI/API path for +creating their app credentials (created by hand through each provider's own developer console), so +there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user +asks for Facebook or Google, configure `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md's +table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI +convention for those the way this skill does for Microsoft. + +## Before making any change, determine + +1. **Is `Jwt` (and the `AuthController`) already configured?** If not, stop - see the prerequisite + above. +2. **Persistent or transient login?** Check whether [Identity](nano-add-identity) is configured. + Doesn't change anything this skill does (the `Jwt.ExternalLogins.Microsoft` config is identical + either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's + sub-repository table (`AuthTransientRepository` vs. the persistent equivalent). Not this skill's + concern to scaffold, only worth knowing when telling the user where the login endpoint lives. +3. **Development only, or does this need to actually work in Staging/Production?** If the user only + wants to test locally, do the appsettings step below and stop - skip the CI/Kubernetes sections + entirely rather than wiring up infrastructure nobody asked for yet. +4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for + Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't + guess a port/path. + +## appsettings.json - Jwt.ExternalLogins.Microsoft + +Base `appsettings.json`, nested under the existing `Jwt` block: + +```json +"ExternalLogins": { + "Microsoft": { + "TenantId": null, + "ClientId": null, + "ClientSecret": null, + "Scopes": [ "openid", "profile", "email" ] + } +} +``` + +`appsettings.Development.json` - same shape, still `null`. **Do not hardcode real values here**, +unlike the shared JWT Development key pair - a Microsoft app registration is tied to a real Azure +tenant, not a throwaway pair everyone in the codebase can share. The developer fills these in +locally themselves, after creating their own Entra ID app registration (Azure Portal → Microsoft +Entra ID → App registrations → New registration → Web redirect URI matching whatever client will +call this → Certificates & secrets → new client secret, copied immediately since it's shown once → +note the Application (client) ID and Directory (tenant) ID). No Graph API permission is needed +beyond the default - `openid`/`profile`/`email` only affect what lands in the `id_token`, not access +to any resource. + +`appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section +below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes +secret, the same way `Jwt.PublicKey`/`PrivateKey` are - not present in any config file for those +environments. + +## Staging/Production - self-provisioning, self-rotating CI step + +This is the part that's actually scriptable, unlike Facebook/Google. Add one workflow-level env var: + +```yaml +env: + AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} +``` + +`vars`, not `secrets` - it's just a URL, not sensitive. Ask the user for its value (the real +deployed client's callback URL) rather than defaulting to a placeholder. + +Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` +(needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the +Kubernetes step): + +```yaml +- name: Setup App Registration + shell: pwsh + run: | + $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; + $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + + if (-not $env:AUTH_MICROSOFT_CLIENT_ID) + { + az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + + $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + } + else + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + } + + $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $staleCredentialIds = az ad app credential list --id $env:AUTH_MICROSOFT_CLIENT_ID --query "sort_by(@, &startDateTime)[:-3].keyId" -o tsv; + + foreach ($keyId in ($staleCredentialIds -split "`n" | Where-Object { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV; + echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV; +``` + +What this does, and why it's shaped this way: + +- **Idempotent app registration.** Looks the app up by display name first; creates it only if + missing, otherwise just keeps its redirect URI in sync. `TenantId` needs no separate handling at + all - it's already the workflow's own `$env:AZURE_TENANT_ID` (used for `az login`), so the + Kubernetes secret below reads that directly rather than a Microsoft-specific copy of it. +- **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every + existing secret - any pod still running the previous deployment's env vars would find its + `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps + working until those pods cycle out. +- **Prune to the newest 3** (`sort_by(@, &startDateTime)[:-3]`) so the app registration doesn't + accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one + actually stops working - comfortably outlasts a normal rolling update. Adjust the `-3` if a + different grace period is wanted, but don't drop the pruning step entirely or the app registration + grows an unbounded credential list. +- **`::add-mask::` immediately after generating the secret.** GitHub only auto-masks values sourced + from `secrets.*` - this one is never stored as a GitHub secret (see below), so nothing masks it by + default unless this line does. Keep it directly after the `credential reset` call, before anything + else touches `$env:AUTH_MICROSOFT_CLIENT_SECRET`. +- **No `AUTH_MICROSOFT_CLIENT_SECRET` GitHub secret, ever.** Unlike the JWT keys (generated once, + offline, stored as `{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY`), this workflow never + depends on a value surviving between runs - every run produces and exports its own. Don't + introduce one; it would just go stale the next time this step rotates the secret. + +## Kubernetes + +New file, `.kubernetes/auth-microsoft-secret.yaml`: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AZURE_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, before +`deployment.yaml`/`stateful-set.yaml`. Also add +`.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` to `{name}.sln`'s +`.kubernetes` `SolutionItems` block (per AGENTS.md's Solution Structure note) - new files under +`.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. + +`.kubernetes/deployment.yaml`'s container `env`, alongside the JWT key entries: + +```yaml +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id +- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +## Reference implementation + +`Nano.Lessons/Api.Auth.External.Microsoft` is this exact setup end-to-end (transient login, no +Identity) - its `.github/workflows/build-and-deploy.yml`, `.kubernetes/auth-microsoft-secret.yaml`, +and `.kubernetes/deployment.yaml` are the working, tested version of everything above. When in +doubt about exact formatting or step ordering, diff against that lesson rather than guessing. + +## After making the change + +- Show the user every file touched, grouped by concern: appsettings per environment, and - if + Staging/Production was in scope - the workflow env var + new step, the new Kubernetes secret file, + the `deployment.yaml` additions, and the `.sln` entry. +- If step 3 found this was Development-only, that's the whole response - don't wire up the CI/ + Kubernetes half unasked. +- Remind the user to create their own Entra ID app registration for Development (the manual steps + above) before testing locally - this skill can't do that part for them, only Staging/Production is + scriptable. +- If they also want Facebook or Google, say plainly that this skill doesn't cover those - configure + `Jwt.ExternalLogins.Facebook`/`.Google` by hand per AGENTS.md, and ask how they want the secret + stored for Staging/Production rather than assuming this skill's Microsoft-specific pattern applies. diff --git a/AGENTS.md b/AGENTS.md index 4255c02b..997dd427 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1467,6 +1467,23 @@ var publicKey = rsa.ExportRSAPublicKeyPem().Replace("-----BEGIN RSA PUBLIC KEY-- var privateKey = rsa.ExportRSAPrivateKeyPem().Replace("-----BEGIN RSA PRIVATE KEY-----", "").Replace("-----END RSA PRIVATE KEY-----", "").Replace("\n", ""); ``` +**External login providers** (`Jwt.ExternalLogins`) are built-in and config-only — no `BaseAuthExternalRepository` +implementation needed for Facebook/Google/Microsoft, only the settings from the table above. Each uses a different +`TFlow` (see [Custom external provider](#custom-external-provider) below for what that means), which determines +what the client sends: + +| Provider | Flow | Client sends | Credentials come from | +| ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `Facebook` | `ImplicitFlow` | `AccessToken` — obtained client-side via Facebook's own SDK/login flow, then passed straight through. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `ImplicitFlow` | `AccessToken` — same shape as Facebook, obtained via Google's own client-side sign-in. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +Facebook and Google credentials are created by hand through each provider's own developer console — there's no +CLI/API path worth scripting for either. Microsoft is the exception: an Entra ID app registration (and its client +secret, which needs periodic rotation) can be fully scripted with the Azure CLI, so use the `nano-add-authentication-microsoft` +skill for that provider instead of configuring it by hand — it wires up the app registration and a self-rotating +client secret as a CI step, following the pattern in `Nano.Lessons/Api.Auth.External.Microsoft`. + **Root login** is a statically-configured, transient JWT login — no identity store involved. Useful in `Development` when testing a service in isolation, or for console apps authenticating via the API client with no specific user account. Logging in as root auto-assigns the `administrator` role. From a6291271401938a42c132cf907649b4c8558261d Mon Sep 17 00:00:00 2001 From: vivet Date: Thu, 17 Sep 2026 15:17:45 +0200 Subject: [PATCH 04/10] Updated Microsoft authentication implementation --- Nano.App.Api/Config/MicrosoftOptions.cs | 3 +- .../AuthExternalMicrosoftRepository.cs | 65 +++++----- Nano.App.Api/README.md | 120 +++++++++++++++++- 3 files changed, 152 insertions(+), 36 deletions(-) diff --git a/Nano.App.Api/Config/MicrosoftOptions.cs b/Nano.App.Api/Config/MicrosoftOptions.cs index 572bd0cf..b28ce31d 100644 --- a/Nano.App.Api/Config/MicrosoftOptions.cs +++ b/Nano.App.Api/Config/MicrosoftOptions.cs @@ -26,7 +26,8 @@ public class MicrosoftOptions public virtual required string ClientSecret { get; set; } /// - /// OAuth scopes. + /// OAuth scopes. Must include "openid" (and should include "profile" and "email") so the + /// token response includes an id_token with the claims the login flow reads (oid/name/email). /// [Required] public virtual string[] Scopes { get; set; } = []; diff --git a/Nano.App.Api/Mvc/Authentication/AuthExternalMicrosoftRepository.cs b/Nano.App.Api/Mvc/Authentication/AuthExternalMicrosoftRepository.cs index a91ffd20..2d1791c0 100644 --- a/Nano.App.Api/Mvc/Authentication/AuthExternalMicrosoftRepository.cs +++ b/Nano.App.Api/Mvc/Authentication/AuthExternalMicrosoftRepository.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; +using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Net.Http; @@ -28,25 +29,21 @@ public override async Task AuthenticateAsync(AuthCod var tokenHandler = new JwtSecurityTokenHandler(); - //string accessToken; - //string? refreshToken; - using var httpRequestMessage = new HttpRequestMessage(); httpRequestMessage.Method = HttpMethod.Post; httpRequestMessage.RequestUri = new Uri($"https://login.microsoftonline.com/{this.options.TenantId}/oauth2/v2.0/token"); - using var formContent = new MultipartFormDataContent(); - - formContent.Add(new StringContent(this.options.ClientId), "client_id"); - formContent.Add(new StringContent(this.options.ClientSecret), "client_secret"); - formContent.Add(new StringContent("authorization_code"), "grant_type"); - formContent.Add(new StringContent(flow.Code), "code"); - formContent.Add(new StringContent(flow.CodeVerifier), "code_verifier"); - formContent.Add(new StringContent(flow.RedirectUri), "redirect_uri"); - formContent.Add(new StringContent(this.options.Scopes.Aggregate(string.Empty, (current, x) => current + $"{x} ")), "scope"); - - httpRequestMessage.Content = formContent; + httpRequestMessage.Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = this.options.ClientId, + ["client_secret"] = this.options.ClientSecret, + ["grant_type"] = "authorization_code", + ["code"] = flow.Code, + ["code_verifier"] = flow.CodeVerifier, + ["redirect_uri"] = flow.RedirectUri, + ["scope"] = string.Join(" ", this.options.Scopes) + }); var httpResponse = await httpClient .SendAsync(httpRequestMessage, cancellationToken); @@ -78,10 +75,17 @@ public override async Task AuthenticateAsync(AuthCod var refreshToken = content["refresh_token"]?.ToString(); + var idToken = content["id_token"]?.ToString(); + + if (idToken == null) + { + throw new NullReferenceException(nameof(idToken)); + } + var jwtToken = tokenHandler - .ReadJwtToken(accessToken); + .ReadJwtToken(idToken); - var id = jwtToken?.Payload + var id = jwtToken.Payload .Where(x => x.Key == "oid") .Select(x => x.Value?.ToString()) .FirstOrDefault(); @@ -91,24 +95,24 @@ public override async Task AuthenticateAsync(AuthCod throw new NullReferenceException(nameof(id)); } - var name = jwtToken?.Payload + var name = jwtToken.Payload .Where(x => x.Key == "name") .Select(x => x.Value?.ToString()) .FirstOrDefault(); if (name == null) { - throw new NullReferenceException(nameof(id)); + throw new NullReferenceException(nameof(name)); } - var email = jwtToken?.Payload - .Where(x => x.Key == "upn") + var email = jwtToken.Payload + .Where(x => x.Key is "email" or "preferred_username") .Select(x => x.Value?.ToString()) .FirstOrDefault(); if (email == null) { - throw new NullReferenceException(nameof(id)); + throw new NullReferenceException(nameof(email)); } return new ExternalAuthenticationData @@ -136,15 +140,14 @@ public override async Task AuthenticateRefreshAsync httpRequestMessage.Method = HttpMethod.Post; httpRequestMessage.RequestUri = new Uri($"https://login.microsoftonline.com/{this.options.TenantId}/oauth2/v2.0/token"); - using var formContent = new MultipartFormDataContent(); - - formContent.Add(new StringContent(this.options.ClientId), "client_id"); - formContent.Add(new StringContent(this.options.ClientSecret), "client_secret"); - formContent.Add(new StringContent("refresh_token"), "grant_type"); - formContent.Add(new StringContent(refreshToken), "refresh_token"); - formContent.Add(new StringContent(this.options.Scopes.Aggregate(string.Empty, (current, x) => current + $"{x} ")), "scope"); - - httpRequestMessage.Content = formContent; + httpRequestMessage.Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = this.options.ClientId, + ["client_secret"] = this.options.ClientSecret, + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["scope"] = string.Join(" ", this.options.Scopes) + }); var httpResponse = await this.httpClient .SendAsync(httpRequestMessage, cancellationToken); @@ -155,7 +158,7 @@ public override async Task AuthenticateRefreshAsync var content = JsonConvert.DeserializeObject(stringContent); var error = content?["error"]?.ToString(); - var errorDescription = content?["error"]?.ToString() ?? "Unknown"; + var errorDescription = content?["error_description"]?.ToString() ?? "Unknown"; if (error != null) { diff --git a/Nano.App.Api/README.md b/Nano.App.Api/README.md index e17d87ce..f3be0e9f 100644 --- a/Nano.App.Api/README.md +++ b/Nano.App.Api/README.md @@ -1809,12 +1809,17 @@ For a built-in provider, the following configuration can be added. "Facebook": { "AppId": null, "AppSecret": null, - "Scopes": [ ] + "Scopes": [ "public_profile", "email", "user_birthday" ] } } } } ``` + +`Scopes` must include `email` (`public_profile` is granted by default but listing it explicitly is harmless) so Nano can read the `id`/`name`/`email` fields it +requests from the Facebook Graph API; add `user_birthday` too if the `birthday` field is needed. The Facebook App Id/Secret must be created manually through +[Meta for Developers](https://developers.facebook.com) - there is no API/CLI path to script this the way there is for Microsoft (see below). + **Google** | Setting | Type | Default | Description | @@ -1831,13 +1836,17 @@ For a built-in provider, the following configuration can be added. "Google": { "ClientId": null, "ClientSecret": null, - "Scopes": [ ] + "Scopes": [ "openid", "profile", "email" ] } } } } ``` +`Scopes` must include `openid` (and should include `profile`/`email`) - Nano validates the value passed in as a Google ID token and reads its `name`/`email` claims +from it. The Google Client Id/Secret must be created manually through the [Google Cloud Console](https://console.cloud.google.com)'s OAuth client setup - there is +no API/CLI path to script this the way there is for Microsoft (see below). + **Microsoft** | Setting | Type | Default | Description | @@ -1856,14 +1865,117 @@ For a built-in provider, the following configuration can be added. "TenantId": null, "ClientId": null, "ClientSecret": null, - "Scopes": [ ] + "Scopes": [ "openid", "profile", "email" ] } } } } ``` -> ⚠️ The external provider application must be configured with at least the following scopes: `id`, `email`, and `username`. +`Scopes` must include `openid` (and should include `profile`/`email`) - Nano reads the login's identity claims (`oid`/`name`/`email`) from the token response's `id_token`, +which is only returned when `openid` is requested. + +Unlike Facebook/Google above, and unlike the JWT keys and `RootLogin` elsewhere on this page, Microsoft's Entra ID app registration can be created and rotated +entirely through the Azure CLI - so instead of a one-time manual setup stored as a static secret, its credentials are provisioned and rotated by the GitHub Actions +workflow itself, in a `Setup App Registration` step. `TenantId` is simply the workflow's own `AZURE_TENANT_ID` (no separate value needed), `ClientId` is looked up +fresh every run, and `ClientSecret` is reissued every run and never persisted as a GitHub secret. + +```yaml +env: + AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} +``` + +```yaml +- name: Setup App Registration + shell: pwsh + run: | + $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; + $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + + if (-not $env:AUTH_MICROSOFT_CLIENT_ID) + { + az ad app create ` + --display-name $env:APP_DISPLAY_NAME ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + + $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; + } + else + { + az ad app update ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; + } + + $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --append ` + --display-name $env:SECRET_DISPLAY_NAME ` + --years 1 ` + --query "password" -o tsv; + + echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET"; + + $staleCredentialIds = az ad app credential list --id $env:AUTH_MICROSOFT_CLIENT_ID --query "sort_by(@, &startDateTime)[:-3].keyId" -o tsv; + + foreach ($keyId in ($staleCredentialIds -split "`n" | Where-Object { $_ })) + { + az ad app credential delete ` + --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --key-id $keyId; + } + + echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV; + echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV; +``` + +`--append` adds the new client secret alongside any existing ones instead of invalidating them immediately, so pods still running the previous deployment's secret keep +working through a rolling update. Credentials are then pruned down to the newest 3, giving an older secret roughly 3 deploys of grace before it actually stops working. + +Create a Kubernetes secret that stores the Microsoft app registration's credentials, allowing them to be securely consumed by the application. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: auth-microsoft-secret + namespace: %KUBERNETES_NAMESPACE% +type: Opaque +stringData: + tenant-id: %AZURE_TENANT_ID% + client-id: %AUTH_MICROSOFT_CLIENT_ID% + client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% +``` + +Finally, reference the secret in the application `deployment.yaml` or `cronjob.yaml`. + +```yaml +spec: + template: + spec: + containers: + env: + - name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: tenant-id + - name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-id + - name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret + valueFrom: + secretKeyRef: + name: auth-microsoft-secret + key: client-secret +``` + +Try it out yourself using the **[Api.Auth.External.Microsoft](https://github.com/Nano-Core/Nano.Lessons/blob/master/Api.Auth.External.Microsoft)** example, which has this +wiring end-to-end. Implementing a custom external authentication provider in Nano is straightforward. Create a class that derives from `BaseAuthExternalRepository` and provide a provider name via the constructor. The base class implements the `IAuthExternalRepository` interface, which requires you to implement the abstract methods `AuthenticateAsync` and `AuthenticateRefreshAsync`. From b8203cf8f4d6b4ee9c68d6e69f9056d2b07b2082 Mon Sep 17 00:00:00 2001 From: vivet Date: Thu, 17 Sep 2026 19:15:23 +0200 Subject: [PATCH 05/10] Fixed Facebook login --- .../SKILL.md | 71 ++++++++++++++---- ...ano-add-authentication-microsoft.prompt.md | 72 +++++++++++++++---- AGENTS.md | 4 +- .../AuthExternalFacebookRepository.cs | 48 ++++++++++--- 4 files changed, 159 insertions(+), 36 deletions(-) diff --git a/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/.claude/skills/nano-add-authentication-microsoft/SKILL.md index b7fb6068..eb01d820 100644 --- a/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -39,6 +39,23 @@ convention for those the way this skill does for Microsoft. 4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for Development, scripted for Staging/Production). Ask if the request doesn't name a client — don't guess a port/path. +5. **Which accounts should be able to sign in?** Ask — don't assume. This is the app registration's + `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since + `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL + (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice and + what `Nano.Lessons/Api.Auth.External.Microsoft` and `Nano.Templates/Api.Admin` both use. Whatever + is chosen, remind the user that the client-side code that starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued — that part lives outside Nano and this skill can't set it. ## appsettings.json — Jwt.ExternalLogins.Microsoft @@ -59,11 +76,15 @@ Base `appsettings.json`, nested under the existing `Jwt` block: unlike the shared JWT Development key pair — a Microsoft app registration is tied to a real Azure tenant, not a throwaway pair everyone in the codebase can share. The developer fills these in locally themselves, after creating their own Entra ID app registration (Azure Portal → Microsoft -Entra ID → App registrations → New registration → Web redirect URI matching whatever client will -call this → Certificates & secrets → new client secret, copied immediately since it's shown once → -note the Application (client) ID and Directory (tenant) ID). No Graph API permission is needed -beyond the default — `openid`/`profile`/`email` only affect what lands in the `id_token`, not access -to any resource. +Entra ID → App registrations → New registration → choose the sign-in audience decided above → Web +redirect URI matching whatever client will call this → Certificates & secrets → new client secret, +copied immediately since it's shown once → note the Application (client) ID). No Graph API +permission is needed beyond the default — `openid`/`profile`/`email` only affect what lands in the +`id_token`, not access to any resource. + +For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only +if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is +the same for every developer regardless of which tenant they created the registration in. `appsettings.Staging.json`/`appsettings.Production.json` — **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes @@ -72,15 +93,18 @@ environments. ## Staging/Production — self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add one workflow-level env var: +This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} + AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` — it's just a URL, not sensitive. Ask the user for its value (the real -deployed client's callback URL) rather than defaulting to a placeholder. +`vars`, not `secrets` — neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value +(the real deployed client's callback URL) rather than defaulting to a placeholder, and set +`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above +(e.g. `AzureADMyOrg`). Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -98,7 +122,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience AzureADMyOrg ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -107,9 +131,18 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } + $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) + { + "AzureADMyOrg" { $env:AZURE_TENANT_ID } + "AzureADMultipleOrgs" { "organizations" } + "AzureADandPersonalMicrosoftAccount" { "common" } + "PersonalMicrosoftAccount" { "consumers" } + }; + $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -130,14 +163,19 @@ Kubernetes step): echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV; echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV; + echo "AUTH_MICROSOFT_TENANT_ID=$env:AUTH_MICROSOFT_TENANT_ID" >> $env:GITHUB_ENV; ``` What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI in sync. `TenantId` needs no separate handling at - all — it's already the workflow's own `$env:AZURE_TENANT_ID` (used for `az login`), so the - Kubernetes secret below reads that directly rather than a Microsoft-specific copy of it. + missing, otherwise just keeps its redirect URI/audience in sync. +- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` + directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime — the other three audiences + need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before + making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to + `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for + the common case. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret — any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -168,7 +206,7 @@ metadata: namespace: %KUBERNETES_NAMESPACE% type: Opaque stringData: - tenant-id: %AZURE_TENANT_ID% + tenant-id: %AUTH_MICROSOFT_TENANT_ID% client-id: %AUTH_MICROSOFT_CLIENT_ID% client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% ``` @@ -206,6 +244,13 @@ Identity) — its `.github/workflows/build-and-deploy.yml`, `.kubernetes/auth-mi and `.kubernetes/deployment.yaml` are the working, tested version of everything above. When in doubt about exact formatting or step ordering, diff against that lesson rather than guessing. +One difference: the lesson (and `Nano.Templates/Api.Admin`) hardcode `AzureADMyOrg` directly rather +than reading `$env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE`, and their Kubernetes secret still reads +`%AZURE_TENANT_ID%` rather than `%AUTH_MICROSOFT_TENANT_ID%` — both are intentionally left as the +simpler, single-tenant-only version, since neither needs broader sign-in. Don't "fix" them to match +this skill unless asked; treat this skill's parameterized version as what to scaffold for a *new* +app whose audience was actually asked about in step 5. + ## After making the change - Show the user every file touched, grouped by concern: appsettings per environment, and — if diff --git a/.github/prompts/nano-add-authentication-microsoft.prompt.md b/.github/prompts/nano-add-authentication-microsoft.prompt.md index 3f19f869..79352245 100644 --- a/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -3,6 +3,7 @@ mode: agent description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application - adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application - requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- + # Nano add Microsoft authentication Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an @@ -39,6 +40,23 @@ convention for those the way this skill does for Microsoft. 4. **Is a redirect URI known?** Needed for the Azure AD app registration either way (manual for Development, scripted for Staging/Production). Ask if the request doesn't name a client - don't guess a port/path. +5. **Which accounts should be able to sign in?** Ask - don't assume. This is the app registration's + `--sign-in-audience`, and it also changes what `TenantId` must hold at runtime, since + `AuthExternalMicrosoftRepository` interpolates it straight into the token endpoint URL + (`https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token`): + + | Who can sign in | `--sign-in-audience` | Runtime `TenantId` | + | --- | --- | --- | + | Only users in your own tenant (default, most common) | `AzureADMyOrg` | the real tenant GUID | + | Users in any Azure AD/Entra org | `AzureADMultipleOrgs` | literal `organizations` | + | Any org + personal Microsoft accounts | `AzureADandPersonalMicrosoftAccount` | literal `common` | + | Personal Microsoft accounts only | `PersonalMicrosoftAccount` | literal `consumers` | + + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice and + what `Nano.Lessons/Api.Auth.External.Microsoft` and `Nano.Templates/Api.Admin` both use. Whatever + is chosen, remind the user that the client-side code that starts the sign-in (MSAL.js or + equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a + code is ever issued - that part lives outside Nano and this skill can't set it. ## appsettings.json - Jwt.ExternalLogins.Microsoft @@ -59,11 +77,15 @@ Base `appsettings.json`, nested under the existing `Jwt` block: unlike the shared JWT Development key pair - a Microsoft app registration is tied to a real Azure tenant, not a throwaway pair everyone in the codebase can share. The developer fills these in locally themselves, after creating their own Entra ID app registration (Azure Portal → Microsoft -Entra ID → App registrations → New registration → Web redirect URI matching whatever client will -call this → Certificates & secrets → new client secret, copied immediately since it's shown once → -note the Application (client) ID and Directory (tenant) ID). No Graph API permission is needed -beyond the default - `openid`/`profile`/`email` only affect what lands in the `id_token`, not access -to any resource. +Entra ID → App registrations → New registration → choose the sign-in audience decided above → Web +redirect URI matching whatever client will call this → Certificates & secrets → new client secret, +copied immediately since it's shown once → note the Application (client) ID). No Graph API +permission is needed beyond the default - `openid`/`profile`/`email` only affect what lands in the +`id_token`, not access to any resource. + +For `TenantId`, use the table above: the real Directory (tenant) ID from the app registration only +if it's `AzureADMyOrg`; otherwise the literal `organizations`/`common`/`consumers` string, which is +the same for every developer regardless of which tenant they created the registration in. `appsettings.Staging.json`/`appsettings.Production.json` - **nothing**. Per the Kubernetes section below, `TenantId`/`ClientId`/`ClientSecret` are injected as environment variables from a Kubernetes @@ -72,15 +94,18 @@ environments. ## Staging/Production - self-provisioning, self-rotating CI step -This is the part that's actually scriptable, unlike Facebook/Google. Add one workflow-level env var: +This is the part that's actually scriptable, unlike Facebook/Google. Add two workflow-level env vars: ```yaml env: AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }} + AUTH_MICROSOFT_SIGN_IN_AUDIENCE: ${{ vars.AUTH_MICROSOFT_SIGN_IN_AUDIENCE }} ``` -`vars`, not `secrets` - it's just a URL, not sensitive. Ask the user for its value (the real -deployed client's callback URL) rather than defaulting to a placeholder. +`vars`, not `secrets` - neither is sensitive. Ask the user for `AUTH_MICROSOFT_REDIRECT_URI`'s value +(the real deployed client's callback URL) rather than defaulting to a placeholder, and set +`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` to whichever `--sign-in-audience` value was decided in step 5 above +(e.g. `AzureADMyOrg`). Add a **"Setup App Registration"** step, after `Build & Push Image` and before `Kubernetes Deploy` (needs `AZURE_TENANT_ID`/an authenticated `az` session from `Azure Login`, and its output feeds the @@ -98,7 +123,7 @@ Kubernetes step): { az ad app create ` --display-name $env:APP_DISPLAY_NAME ` - --sign-in-audience AzureADMyOrg ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -107,9 +132,18 @@ Kubernetes step): { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience $env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } + $env:AUTH_MICROSOFT_TENANT_ID = switch ($env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE) + { + "AzureADMyOrg" { $env:AZURE_TENANT_ID } + "AzureADMultipleOrgs" { "organizations" } + "AzureADandPersonalMicrosoftAccount" { "common" } + "PersonalMicrosoftAccount" { "consumers" } + }; + $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --append ` @@ -130,14 +164,19 @@ Kubernetes step): echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV; echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV; + echo "AUTH_MICROSOFT_TENANT_ID=$env:AUTH_MICROSOFT_TENANT_ID" >> $env:GITHUB_ENV; ``` What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI in sync. `TenantId` needs no separate handling at - all - it's already the workflow's own `$env:AZURE_TENANT_ID` (used for `az login`), so the - Kubernetes secret below reads that directly rather than a Microsoft-specific copy of it. + missing, otherwise just keeps its redirect URI/audience in sync. +- **`AUTH_MICROSOFT_TENANT_ID` is derived from the audience, not reused from `$env:AZURE_TENANT_ID` + directly.** Only `AzureADMyOrg` uses the real tenant GUID at runtime - the other three audiences + need the literal `organizations`/`common`/`consumers` string instead, per the table in "Before + making any change, determine" above. If the app is `AzureADMyOrg`, this still resolves to + `$env:AZURE_TENANT_ID` (the workflow's own tenant, used for `az login`), so nothing changes for + the common case. - **`--append`, not a bare `az ad app credential reset`.** A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find its `ClientSecret` invalid mid-rollout. `--append` adds a new one alongside, so the old secret keeps @@ -168,7 +207,7 @@ metadata: namespace: %KUBERNETES_NAMESPACE% type: Opaque stringData: - tenant-id: %AZURE_TENANT_ID% + tenant-id: %AUTH_MICROSOFT_TENANT_ID% client-id: %AUTH_MICROSOFT_CLIENT_ID% client-secret: %AUTH_MICROSOFT_CLIENT_SECRET% ``` @@ -206,6 +245,13 @@ Identity) - its `.github/workflows/build-and-deploy.yml`, `.kubernetes/auth-micr and `.kubernetes/deployment.yaml` are the working, tested version of everything above. When in doubt about exact formatting or step ordering, diff against that lesson rather than guessing. +One difference: the lesson (and `Nano.Templates/Api.Admin`) hardcode `AzureADMyOrg` directly rather +than reading `$env:AUTH_MICROSOFT_SIGN_IN_AUDIENCE`, and their Kubernetes secret still reads +`%AZURE_TENANT_ID%` rather than `%AUTH_MICROSOFT_TENANT_ID%` - both are intentionally left as the +simpler, single-tenant-only version, since neither needs broader sign-in. Don't "fix" them to match +this skill unless asked; treat this skill's parameterized version as what to scaffold for a *new* +app whose audience was actually asked about in step 5. + ## After making the change - Show the user every file touched, grouped by concern: appsettings per environment, and - if diff --git a/AGENTS.md b/AGENTS.md index 997dd427..f77a9763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1474,8 +1474,8 @@ what the client sends: | Provider | Flow | Client sends | Credentials come from | | ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | -| `Facebook` | `ImplicitFlow` | `AccessToken` — obtained client-side via Facebook's own SDK/login flow, then passed straight through. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | -| `Google` | `ImplicitFlow` | `AccessToken` — same shape as Facebook, obtained via Google's own client-side sign-in. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | +| `Facebook` | `ImplicitFlow` | `AccessToken` — the user access token from Facebook's client-side Login SDK, passed straight through and validated server-side via the Graph API's `debug_token` endpoint. | Meta for Developers app (developers.facebook.com), `AppId`/`AppSecret`. | +| `Google` | `ImplicitFlow` | `AccessToken` — despite the name, this must be the **ID token** (JWT) from Google Identity Services' client-side sign-in, not an OAuth access token; it's validated locally via `GoogleJsonWebSignature.ValidateAsync`. The older `gapi.auth2` library (retired by Google in 2023) issued real OAuth access tokens here, which will fail validation. | Google Cloud Console OAuth client (`ClientId`/`ClientSecret`). | | `Microsoft` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalMicrosoftRepository`). `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `oid`/`name`/`email` claims Nano reads — the `access_token` is not used for identity, only as the stored `ExternalToken`. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | Facebook and Google credentials are created by hand through each provider's own developer console — there's no diff --git a/Nano.App.Api/Mvc/Authentication/AuthExternalFacebookRepository.cs b/Nano.App.Api/Mvc/Authentication/AuthExternalFacebookRepository.cs index a15869e2..8489249b 100644 --- a/Nano.App.Api/Mvc/Authentication/AuthExternalFacebookRepository.cs +++ b/Nano.App.Api/Mvc/Authentication/AuthExternalFacebookRepository.cs @@ -3,6 +3,7 @@ using Nano.Data.Abstractions.Identity.Authentication.Consts; using Nano.Data.Abstractions.Identity.Authentication.Models; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System; using System.Net.Http; using System.Threading; @@ -23,8 +24,8 @@ public override async Task AuthenticateAsync(Implici { ArgumentNullException.ThrowIfNull(flow); - const string HOST = "https://graph.facebook.com"; - const string FIELDS = "id,name,address,email,birthday"; + const string HOST = "https://graph.facebook.com/v21.0"; + const string FIELDS = "id,name,email"; var debugTokenResponse = await httpClient .GetAsync($"{HOST}/debug_token?input_token={flow.AccessToken}&access_token={options.AppId}|{options.AppSecret}", cancellationToken); @@ -61,15 +62,46 @@ public override async Task AuthenticateAsync(Implici var user = await userResponse.Content .ReadAsStringAsync(cancellationToken); - var externalLoginData = JsonConvert.DeserializeObject(user); + var userData = JsonConvert.DeserializeObject(user); - externalLoginData?.ExternalToken = new ExternalAuthenticationToken + if (userData == null) { - Name = BuiltInExternalLogInProviderNames.FACEBOOK, - Token = flow.AccessToken - }; + throw new NullReferenceException(nameof(userData)); + } + + var id = userData["id"]?.ToString(); + + if (id == null) + { + throw new NullReferenceException(nameof(id)); + } + + var name = userData["name"]?.ToString(); + + if (name == null) + { + throw new NullReferenceException(nameof(name)); + } + + var email = userData["email"]?.ToString(); + + if (email == null) + { + throw new NullReferenceException(nameof(email)); + } - return externalLoginData ?? throw new UnauthorizedException(); + return new ExternalAuthenticationData + { + Id = id, + Name = name, + EmailAddress = email, + Username = email, + ExternalToken = new ExternalAuthenticationToken + { + Name = BuiltInExternalLogInProviderNames.FACEBOOK, + Token = flow.AccessToken + } + }; } /// From f1ea24c699331f5d28df29f6597cc176051a54ad Mon Sep 17 00:00:00 2001 From: vivet Date: Thu, 17 Sep 2026 19:47:48 +0200 Subject: [PATCH 06/10] Removed script for copying skills --- .../SKILL.md | 41 +++++- .../skills/nano-add-public-exposure/SKILL.md | 6 + .../SKILL.md | 24 +++- ...ano-add-api-client-configuration.prompt.md | 41 +++++- .../nano-add-public-exposure.prompt.md | 6 + ...-remove-api-client-configuration.prompt.md | 24 +++- AGENTS.md | 81 +++++++++++ sync-agents-md.ps1 | 128 ------------------ 8 files changed, 215 insertions(+), 136 deletions(-) delete mode 100644 sync-agents-md.ps1 diff --git a/.claude/skills/nano-add-api-client-configuration/SKILL.md b/.claude/skills/nano-add-api-client-configuration/SKILL.md index f360b76d..c356462b 100644 --- a/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -1,6 +1,6 @@ --- name: nano-add-api-client-configuration -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,48 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr This is the step that actually makes the `App:Apis` entry take effect — without it, per the gotcha above, nothing gets registered even though the config exists. +## docker-compose.yml (local Development) — do this automatically, every time + +The target must actually run locally alongside this app, or `Host` in the config above resolves to +nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` +section under Api Clients first — this is not an optional follow-up step, it's part of what +"add an Api Client configuration" means; do it in the same change as the config/injection above, +without being asked separately. + +1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a + service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, + nothing to do here. +2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own + `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which + already reflects this) — determines whether the nested block gets `depends_on: [database, + eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file + only if not already present — one instance serves every nested dependency, never one per + dependency. +3. **Add the nested service block**, per AGENTS.md's template — `dockerfile_inline` copying from + `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested + service's port, and `depends_on` wired both onto this app's own primary service (add the new + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's + template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` + incremental-build wiring. + - If it already exists (this app already consumes at least one other Api Client), add the new + target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` + `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`) — don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes — it lives under + `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. + ## After making the change - Show the user every file touched in *this* app — the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore + file touched by the section above. - Confirm the client is actually injected somewhere — if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. +- Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by + `publish-dependencies.ps1`/the incremental MSBuild target — don't leave `docker compose up` + producing an unreachable host for the new client. - If `LogInRoot` was added, restate the Staging/Production secret-handling requirement — don't let a real credential sit in the base file — and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/.claude/skills/nano-add-public-exposure/SKILL.md b/.claude/skills/nano-add-public-exposure/SKILL.md index f7f23173..b997a3bb 100644 --- a/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/.claude/skills/nano-add-public-exposure/SKILL.md @@ -165,3 +165,9 @@ group, so an app can be reachable under multiple domains without per-domain conf - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) — if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/.claude/skills/nano-remove-api-client-configuration/SKILL.md b/.claude/skills/nano-remove-api-client-configuration/SKILL.md index 6a514491..8a1a73b6 100644 --- a/.claude/skills/nano-remove-api-client-configuration/SKILL.md +++ b/.claude/skills/nano-remove-api-client-configuration/SKILL.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step — remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone — say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone — they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched — other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/.github/prompts/nano-add-api-client-configuration.prompt.md b/.github/prompts/nano-add-api-client-configuration.prompt.md index 266d57e3..2a9d5951 100644 --- a/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -1,6 +1,6 @@ --- mode: agent -description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry and injects the client into a controller/worker so it can call another Nano service. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. +description: Wire an existing Nano Api Client into this application - adds the App:Apis configuration entry, injects the client into a controller/worker, and nests the target service into this app's local docker-compose (with its own incremental publish step) so it's actually runnable end-to-end. Use when the user asks to call another Nano service/API from this app, add an API client to a Public API, or compose internal services together in a Nano API, Web, or Console application. --- # Nano add API client configuration @@ -129,13 +129,48 @@ public class MyController(ILogger logger, MyApi myApi) : BaseContr This is the step that actually makes the `App:Apis` entry take effect - without it, per the gotcha above, nothing gets registered even though the config exists. +## docker-compose.yml (local Development) - do this automatically, every time + +The target must actually run locally alongside this app, or `Host` in the config above resolves to +nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` +section under Api Clients first - this is not an optional follow-up step, it's part of what +"add an Api Client configuration" means; do it in the same change as the config/injection above, +without being asked separately. + +1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a + service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, + nothing to do here. +2. **Does the target have a Data and/or Eventing provider configured?** Check the target's own + `Program.cs`/`appsettings.json` (or its own standalone `.docker/docker-compose.yml`, which + already reflects this) - determines whether the nested block gets `depends_on: [database, + eventing]` or neither. Add a shared `database`/`eventing` service to *this* app's compose file + only if not already present - one instance serves every nested dependency, never one per + dependency. +3. **Add the nested service block**, per AGENTS.md's template - `dockerfile_inline` copying from + `./bin/publish/.`, a host port that doesn't collide with this app's own or any other nested + service's port, and `depends_on` wired both onto this app's own primary service (add the new + `svc.*` key there) and, per step 2, onto `database`/`eventing` if applicable. +4. **Wire the publish step into `.docker/docker-compose.dcproj`**: + - If `publish-dependencies.ps1` doesn't exist yet in `.docker/`, create it (per AGENTS.md's + template) and add the `PublishDependentServices` MSBuild target with `Inputs`/`Outputs` + incremental-build wiring. + - If it already exists (this app already consumes at least one other Api Client), add the new + target's `.csproj` publish line to the existing script, and add a new `DependentServiceSources` + `ItemGroup` entry for the target (its main project + its `.Models` project, `.cs`/`.csproj` + globs, excluding `bin`/`obj`) - don't create a second script or a second target. +5. No `.gitignore` entry is needed for the stamp file the script writes - it lives under + `.docker/bin/`, already covered by the solution's standard `**/bin` ignore rule. + ## After making the change - Show the user every file touched in *this* app - the `.csproj` reference (if one was added), - the `appsettings.json` addition, and the injection site. Note that the client class itself - lives in the target service's `.Models` project, not here. + the `appsettings.json` addition, the injection site, and every docker-compose/dcproj/gitignore + file touched by the section above. - Confirm the client is actually injected somewhere - if the request was just "add the client" with no specified consumer yet, say explicitly that nothing is wired up until it's referenced. +- Confirm the target is runnable locally: nested in `docker-compose.yml`, and covered by + `publish-dependencies.ps1`/the incremental MSBuild target - don't leave `docker compose up` + producing an unreachable host for the new client. - If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let a real credential sit in the base file - and whether the target's `auth-root-login-secret` was confirmed to actually exist or is still an open prerequisite on that other app. diff --git a/.github/prompts/nano-add-public-exposure.prompt.md b/.github/prompts/nano-add-public-exposure.prompt.md index 0089dc50..ed491252 100644 --- a/.github/prompts/nano-add-public-exposure.prompt.md +++ b/.github/prompts/nano-add-public-exposure.prompt.md @@ -159,3 +159,9 @@ group, so an app can be reachable under multiple domains without per-domain conf - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill doesn't configure it, only the routing/TLS/DNS layer. +- A publicly-exposed Public API is the most common case of an app aggregating several Api Clients + (see AGENTS.md's `#### Local Development (docker-compose)` under Api Clients) - if this app + already consumes any, or gains one later via `nano-add-api-client-configuration`, each target + needs to be runnable locally too. This skill doesn't set that up itself (it's orthogonal to + public exposure), but it's worth checking it's not missing if the app has Api Clients configured + with no matching nested service in `.docker/docker-compose.yml`. diff --git a/.github/prompts/nano-remove-api-client-configuration.prompt.md b/.github/prompts/nano-remove-api-client-configuration.prompt.md index 2bf5d383..cd886671 100644 --- a/.github/prompts/nano-remove-api-client-configuration.prompt.md +++ b/.github/prompts/nano-remove-api-client-configuration.prompt.md @@ -58,10 +58,32 @@ If step 3 found nothing else in this app uses the target's `.Models` project, re `ProjectReference`/`PackageReference` too. If step 3 found something else still uses it, leave it and say so. +## docker-compose.yml (local Development) + +Mirror of `nano-add-api-client-configuration`'s docker-compose step - remove the target's nested +service *only* if nothing else in this app's compose file still needs it: + +1. **Is anything else in this app still consuming the target?** If step 3 found another client + still using the target's `.Models` project (or any other reason the target is still called), + leave the nested service block, its `DependentServiceSources` entry, and its line in + `publish-dependencies.ps1` alone - say so explicitly. +2. Otherwise, remove: the target's service block from `.docker/docker-compose.yml`, its key from + this app's own primary service's `depends_on`, its `DependentServiceSources` `ItemGroup` entry + from `.docker/docker-compose.dcproj`, and its `dotnet publish` line from + `publish-dependencies.ps1`. +3. **Don't remove the shared `database`/`eventing` services** just because this one dependency is + gone - they're shared across every nested dependency in the compose file; only remove one if + step 2 removes the *last* dependency that needed it (check every remaining nested service's + `depends_on` first). +4. If this was the *only* dependency this app ever nested, remove `publish-dependencies.ps1` + entirely, and the `PublishDependentServices` target and `DependentServiceSources` `ItemGroup` from + `.docker/docker-compose.dcproj`. + ## After making the change - Show the user every file touched in this app, including the `.csproj` reference if it was - removed (or why it was kept, per step 3). + removed (or why it was kept, per step 3), and every docker-compose/dcproj file touched (or left + alone, and why) by the section above. - Note explicitly that the client's own definition in the owning service's `.Models` project was **not** touched - other applications may still consume it. If the user's actual intent was to delete the definition entirely, point them at `nano-remove-api-client` next. diff --git a/AGENTS.md b/AGENTS.md index f77a9763..23b179be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ inside `{name}/`. | `.tests/Tests.{name}/Properties/DoNotParallelize.cs` | ✓ | ✓ | ✓ | Ensures tests are not parallelized. | | `.docker/docker-compose.dcproj` | ✓ | ✓ | ✓ | Docker Compose project used by Visual Studio for local orchestration. | | `.docker/docker-compose.yml` | ✓ | ✓ | ✓ | Docker Compose spec for local (`Development`) orchestration. | +| `.docker/publish-dependencies.ps1` | (✓) | (✓) | (✓) | Publishes every nested Api Client dependency to `bin/publish` locally _(only present once this app consumes at least one Api Client — see [Api Clients § Local Development](#local-development-docker-compose))_. | | `.kubernetes/configmap.yaml` | ✓ | ✓ | ✓ | Kubernetes ConfigMap. | | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | Kubernetes Horizontal Pod Autoscaler. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | Kubernetes Deployment. Mutually exclusive with `stateful-set.yaml` below — an app has one or the other, never both. | @@ -369,6 +370,86 @@ pass the value explicitly and the target doesn't need a real, matching tenant be `[FromQuery] int? includeDepth` through to it for end-to-end include-depth control, see [Include Annotation](#include-annotation). +#### Local Development (docker-compose) + +Every application an Api Client points at (per its `Host` in `App:Apis`) must actually be runnable alongside this +app locally, or `docker compose up` only starts this app while every downstream call fails to connect. Whenever +`nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's +own `.docker/docker-compose.yml` — not just the config. + +**Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no +`COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the +*primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service +block gets no such treatment, and building it from source with the SDK image inside Docker would need this +solution's private NuGet feed credentials available *inside the container* — not something to solve by baking +credentials into an image. Instead, each dependency is published **locally** (where the developer's own NuGet +credentials already work) into a `bin/publish` folder, and the compose service just `COPY`s that output into a +bare runtime image: + +```yaml +svc.mytarget: + image: svc-mytarget + hostname: svc-mytarget + restart: on-failure + ports: + - 8181:8080 # unique per nested service - avoid colliding with this app's own 8080/4443 + build: + context: ../../Svc.MyTarget/Svc.MyTarget + dockerfile_inline: | + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + WORKDIR /app + COPY ./bin/publish/. . + ENTRYPOINT ["dotnet", "Svc.MyTarget.dll"] + environment: + ASPNETCORE_HTTP_PORTS: "" + depends_on: # only if the target actually has a Data/Eventing provider configured + - database + - eventing + networks: + - network +``` + +A single shared `database` (MySql) and `eventing` (RabbitMq) service serves every nested dependency in the +compose file (one container each, not one per service) — add them only if not already present, and only wire a +dependency's `depends_on` to them if that dependency actually has a data/eventing provider configured (check its +own `Program.cs`; a dependency with neither gets no `depends_on` at all, matching its own standalone +`docker-compose.yml`). + +**Publishing happens automatically on every build, incrementally.** The `.docker/docker-compose.dcproj` gets: + +1. A `publish-dependencies.ps1` script (next to the `.yml`) that `dotnet publish`es every nested dependency to its + own `bin/publish` folder. +2. A `PublishDependentServices` MSBuild target, hooked to `BeforeTargets="DockerPrepareForBuild"`, with `Inputs` + set to a glob of every dependency's (and its `.Models` project's) `.cs`/`.csproj` files and `Outputs` pointing + at a `bin\publish-dependencies.stamp` file the script touches on success — so MSBuild's normal incremental-build + comparison skips the whole publish pass when nothing actually changed, instead of republishing on every single + `docker compose up`. The stamp lives under `.docker\bin\`, not the `.docker` root, purely so it falls under the + solution's existing `**/bin` ignore rule instead of needing its own `.gitignore` entry. + +```xml + + + + + + + +``` + +This means hitting F5 is the only step a developer needs — VS builds the `docker-compose` project before +launching it, which runs this target, which republishes only the dependencies whose source actually changed, +before `docker compose up` ever touches the network. No `.gitignore` entry is needed for the stamp file itself — +it lives under `.docker\bin\`, already covered by the solution's standard `**/bin` ignore rule (the script creates +that `bin` folder if it doesn't exist yet). + +⚠ This whole mechanism exists for *local* `Development` orchestration only. `Staging`/`Production` never build +this way — each service has its own real `Dockerfile` (multi-stage, built from source in CI, where the pipeline's +own NuGet credentials are already available) and its own Kubernetes deployment; nothing here changes that. + ### Start-Up Tasks One-time initialization work that must complete before the application starts accepting traffic (or, for diff --git a/sync-agents-md.ps1 b/sync-agents-md.ps1 deleted file mode 100644 index 4cace57d..00000000 --- a/sync-agents-md.ps1 +++ /dev/null @@ -1,128 +0,0 @@ -<# -.SYNOPSIS - Copies Nano.Library's AGENTS.md, .claude folder (Claude Code skills), .github/prompts folder - (Copilot prompt files), .github/copilot-instructions.md (Copilot always-on context), and - .vscode/settings.json (enables prompt file discovery in VS Code) into the relevant subfolders of - the sibling Nano.Templates, Nano.Lessons, and .vsTemplates repos, overwriting. The destination - .claude and .github/prompts folders are deleted and recreated from source on every run, so a - skill or prompt renamed/removed in Nano.Library is also removed from every synced folder, not - just left stale alongside the new ones. - -.DESCRIPTION - Run this from within Nano.Library itself. It expects Nano.Templates, Nano.Lessons, and .vsTemplates - to be sibling directories one level up (e.g. Nano.Library, Nano.Templates, Nano.Lessons, and - .vsTemplates all under C:\Development\Nano-Core). Re-run any time AGENTS.md, .claude/, - .github/prompts/, .github/copilot-instructions.md, or .vscode/settings.json changes in Nano.Library - to propagate the update. - - - Nano.Templates: copied into every top-level folder that is an actual Nano application (contains a - Program.cs anywhere under it, excluding bin/obj) - this excludes shared library folders like - Lib.Emailing/Lib.Images. - - Nano.Lessons: copied into every top-level folder that is not completely empty - this excludes - reserved/placeholder lesson folders that don't have any content yet. - - .vsTemplates: copied into every dotnet-new template folder under - .vsTemplates\NanoCore.Templates\content\ that is an actual Nano application (same Program.cs - check as Nano.Templates) - these are the folders VS's "Create a new project" and `dotnet new` - actually scaffold from, so they need the same skills/AGENTS.md as everywhere else. - -.EXAMPLE - cd C:\Development\Nano-Core\Nano.Library - .\sync-agents-md.ps1 -#> - -$ErrorActionPreference = "Stop" - -$libraryRoot = $PSScriptRoot -$root = Split-Path $libraryRoot -Parent -$sourcePath = Join-Path $libraryRoot "AGENTS.md" -$claudeSourcePath = Join-Path $libraryRoot ".claude" -$promptsSourcePath = Join-Path $libraryRoot ".github\prompts" -$copilotInstructionsSourcePath = Join-Path $libraryRoot ".github\copilot-instructions.md" -$vscodeSettingsSourcePath = Join-Path $libraryRoot ".vscode\settings.json" - -if (-not (Test-Path $sourcePath)) { - Write-Error "Source file not found: $sourcePath. Run this script from within the Nano.Library folder." - exit 1 -} - -function Copy-ToQualifyingFolders { - param( - [string]$RepoPath, - [string]$DisplayName, - [scriptblock]$Qualifies - ) - - $repoPath = $RepoPath - - if (-not (Test-Path $repoPath)) { - Write-Warning "Skipping '$DisplayName' - folder not found at $repoPath" - return - } - - $subfolders = Get-ChildItem -Path $repoPath -Directory | Where-Object { $_.Name -notmatch '^\.' } - - foreach ($folder in $subfolders) { - if (& $Qualifies $folder.FullName) { - $destinationPath = Join-Path $folder.FullName "AGENTS.md" - Copy-Item -Path $sourcePath -Destination $destinationPath -Force - Write-Output ("Copied AGENTS.md to " + $destinationPath) - - if (Test-Path $claudeSourcePath) { - $claudeDestinationPath = Join-Path $folder.FullName ".claude" - if (Test-Path $claudeDestinationPath) { - Remove-Item -Path $claudeDestinationPath -Recurse -Force - } - New-Item -Path $claudeDestinationPath -ItemType Directory -Force | Out-Null - Copy-Item -Path (Join-Path $claudeSourcePath "*") -Destination $claudeDestinationPath -Recurse -Force - Write-Output ("Copied .claude to " + $claudeDestinationPath) - } - - if (Test-Path $promptsSourcePath) { - $promptsDestinationPath = Join-Path $folder.FullName ".github\prompts" - if (Test-Path $promptsDestinationPath) { - Remove-Item -Path $promptsDestinationPath -Recurse -Force - } - New-Item -Path $promptsDestinationPath -ItemType Directory -Force | Out-Null - Copy-Item -Path (Join-Path $promptsSourcePath "*") -Destination $promptsDestinationPath -Recurse -Force - Write-Output ("Copied .github/prompts to " + $promptsDestinationPath) - } - - if (Test-Path $copilotInstructionsSourcePath) { - $copilotInstructionsDestinationPath = Join-Path $folder.FullName ".github\copilot-instructions.md" - New-Item -Path (Join-Path $folder.FullName ".github") -ItemType Directory -Force | Out-Null - Copy-Item -Path $copilotInstructionsSourcePath -Destination $copilotInstructionsDestinationPath -Force - Write-Output ("Copied .github/copilot-instructions.md to " + $copilotInstructionsDestinationPath) - } - - if (Test-Path $vscodeSettingsSourcePath) { - $vscodeSettingsDestinationPath = Join-Path $folder.FullName ".vscode\settings.json" - New-Item -Path (Join-Path $folder.FullName ".vscode") -ItemType Directory -Force | Out-Null - Copy-Item -Path $vscodeSettingsSourcePath -Destination $vscodeSettingsDestinationPath -Force - Write-Output ("Copied .vscode/settings.json to " + $vscodeSettingsDestinationPath) - } - } - } -} - -$hasProgramCs = { - param($folderPath) - $hasProgram = Get-ChildItem -Path $folderPath -Filter "Program.cs" -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -notmatch '\\(bin|obj)\\' } | - Select-Object -First 1 - return $null -ne $hasProgram -} - -# Nano.Templates: copy into every application folder (contains a Program.cs somewhere, excluding bin/obj) -Copy-ToQualifyingFolders -RepoPath (Join-Path $root "Nano.Templates") -DisplayName "Nano.Templates" -Qualifies $hasProgramCs - -# Nano.Lessons: copy into every folder that is not completely empty -Copy-ToQualifyingFolders -RepoPath (Join-Path $root "Nano.Lessons") -DisplayName "Nano.Lessons" -Qualifies { - param($folderPath) - $anyFile = Get-ChildItem -Path $folderPath -Recurse -File -ErrorAction SilentlyContinue | Select-Object -First 1 - return $null -ne $anyFile -} - -# .vsTemplates: copy into every dotnet-new template folder under NanoCore.Templates\content\ that is -# an actual Nano application (same Program.cs check as Nano.Templates) - these are the folders VS's -# "Create a new project" and `dotnet new` scaffold from directly. -Copy-ToQualifyingFolders -RepoPath (Join-Path $root ".vsTemplates\NanoCore.Templates\content") -DisplayName ".vsTemplates" -Qualifies $hasProgramCs From 9713c687d7b91e520a7fb15d6ac1e3f1ca8c495e Mon Sep 17 00:00:00 2001 From: vivet Date: Thu, 17 Sep 2026 19:52:26 +0200 Subject: [PATCH 07/10] Updated --- .../skills/nano-add-api-client-configuration/SKILL.md | 4 +++- .../prompts/nano-add-api-client-configuration.prompt.md | 4 +++- AGENTS.md | 9 ++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.claude/skills/nano-add-api-client-configuration/SKILL.md b/.claude/skills/nano-add-api-client-configuration/SKILL.md index c356462b..561ff02c 100644 --- a/.claude/skills/nano-add-api-client-configuration/SKILL.md +++ b/.claude/skills/nano-add-api-client-configuration/SKILL.md @@ -135,7 +135,9 @@ The target must actually run locally alongside this app, or `Host` in the config nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` section under Api Clients first — this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. +without being asked separately. **Applies to Console apps too** — a worker consuming an Api Client +needs its target runnable locally the same way an API/Web consumer does; the only difference is a +Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a service block whose `hostname`/`image` matches the target — e.g. `svc-mytarget`.) If yes, diff --git a/.github/prompts/nano-add-api-client-configuration.prompt.md b/.github/prompts/nano-add-api-client-configuration.prompt.md index 2a9d5951..ec64ef04 100644 --- a/.github/prompts/nano-add-api-client-configuration.prompt.md +++ b/.github/prompts/nano-add-api-client-configuration.prompt.md @@ -135,7 +135,9 @@ The target must actually run locally alongside this app, or `Host` in the config nothing when you `docker compose up`. Read AGENTS.md's `#### Local Development (docker-compose)` section under Api Clients first - this is not an optional follow-up step, it's part of what "add an Api Client configuration" means; do it in the same change as the config/injection above, -without being asked separately. +without being asked separately. **Applies to Console apps too** - a worker consuming an Api Client +needs its target runnable locally the same way an API/Web consumer does; the only difference is a +Console app's own compose service has no `ports` of its own to worry about colliding with. 1. **Is the target already nested in this app's `.docker/docker-compose.yml`?** (Check for a service block whose `hostname`/`image` matches the target - e.g. `svc-mytarget`.) If yes, diff --git a/AGENTS.md b/AGENTS.md index 23b179be..bad804b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -377,6 +377,13 @@ app locally, or `docker compose up` only starts this app while every downstream `nano-add-api-client-configuration` adds a new `App:Apis` entry, it also nests the target service into this app's own `.docker/docker-compose.yml` — not just the config. +**Applies equally to Console applications.** This isn't a Public/Admin-API-only concern — a Console app (a +run-to-completion job or worker) consuming an Api Client needs its target runnable locally exactly the same way +(e.g. a sign-up job calling `Svc.Accounts`/`Svc.Emailing`). The only difference is a Console app's own compose +service has no `ports` of its own to collide with (no HTTP surface — see [Authentication forwarding](#authentication-forwarding)'s +note that Console workers typically call only anonymous endpoints, or need `LogInRoot`); the nested dependency +services still need their own unique host ports, same as for an API/Web consumer. + **Why the target isn't built with a normal multi-stage `Dockerfile`**: this app's own `Dockerfile.Local` has no `COPY`/build stage at all — Visual Studio's Container Tools injects that step automatically, but only for the *primary* project being debugged (the one the `.dcproj` names via `DockerServiceName`). A dependency's own service @@ -392,7 +399,7 @@ svc.mytarget: hostname: svc-mytarget restart: on-failure ports: - - 8181:8080 # unique per nested service - avoid colliding with this app's own 8080/4443 + - 8181:8080 # unique per nested service - avoid colliding with this app's own ports (if any; Console apps have none) or any other nested service's build: context: ../../Svc.MyTarget/Svc.MyTarget dockerfile_inline: | From a1813846e809186314c0cdec7c64d39ab687514a Mon Sep 17 00:00:00 2001 From: vivet Date: Fri, 18 Sep 2026 10:31:27 +0200 Subject: [PATCH 08/10] Updated skills --- .../nano-add-authentication-apikey/SKILL.md | 26 ++- .../nano-add-authentication-jwt/SKILL.md | 52 +++++- .../skills/nano-add-data-provider/SKILL.md | 14 +- .../nano-add-eventing-provider/SKILL.md | 12 +- .claude/skills/nano-add-identity/SKILL.md | 39 ++++- .../skills/nano-add-public-exposure/SKILL.md | 28 ++- .../skills/nano-add-storage-provider/SKILL.md | 12 +- .github/prompts/nano-add-api-client.prompt.md | 163 +++++------------- .../nano-add-authentication-apikey.prompt.md | 31 +++- .../nano-add-authentication-jwt.prompt.md | 152 +++++++++++++++- ...ano-add-authentication-microsoft.prompt.md | 3 +- .../nano-add-azure-managed-identity.prompt.md | 3 + .../prompts/nano-add-data-provider.prompt.md | 71 +++++--- .../nano-add-eventing-provider.prompt.md | 22 +-- .github/prompts/nano-add-identity.prompt.md | 105 ++++++++--- .../nano-add-logging-provider.prompt.md | 10 +- .github/prompts/nano-add-metrics.prompt.md | 19 +- .../nano-add-public-exposure.prompt.md | 34 +++- .../prompts/nano-add-startup-task.prompt.md | 9 +- .../nano-add-storage-provider.prompt.md | 58 +++++-- .../prompts/nano-remove-api-client.prompt.md | 95 ++++++---- ...ano-remove-authentication-apikey.prompt.md | 17 ++ .../nano-remove-authentication-jwt.prompt.md | 73 +++++++- ...no-remove-azure-managed-identity.prompt.md | 29 +++- .../nano-remove-data-provider.prompt.md | 75 +++++--- .../nano-remove-health-checks.prompt.md | 2 - .../prompts/nano-remove-identity.prompt.md | 72 ++++++-- AGENTS.md | 41 ++++- Nano.App.Api/README.md | 12 +- 29 files changed, 940 insertions(+), 339 deletions(-) diff --git a/.claude/skills/nano-add-authentication-apikey/SKILL.md b/.claude/skills/nano-add-authentication-apikey/SKILL.md index e466061b..4cfc9bd5 100644 --- a/.claude/skills/nano-add-authentication-apikey/SKILL.md +++ b/.claude/skills/nano-add-authentication-apikey/SKILL.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it — if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature — see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too — don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate — so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** — this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config — tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -91,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in — pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) — from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) — from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time — raw `X-Api-Key` values now checked directly against + internet traffic — rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. diff --git a/.claude/skills/nano-add-authentication-jwt/SKILL.md b/.claude/skills/nano-add-authentication-jwt/SKILL.md index fe892035..c3c125cd 100644 --- a/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -48,10 +48,20 @@ repository backs a given external login in that case — not repeated here. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` — nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** — per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all — see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider — see "External Login" below) — ask which, and - whether a custom provider implementation is needed, before proceeding. + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) — if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -127,6 +137,39 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it — it's not always safe to add.** Nano auto-maps the +built-in transient external-login endpoint (`/auth/login/external/{provider}/transient`) whenever +*any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — +see `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked +by type scan, not by whether this specific controller is the one deriving it. That endpoint binds +the request body straight into `LogInExternal` and merges its `TransientClaims`/ +`TransientRoles` **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`). Concretely: once this app is in transient auth +(step 2) with any external login provider configured, adding this controller means **any +anonymous caller can post `{"transientClaims": {"IsAdmin": "true"}}` to that endpoint and receive +back a validly-signed token carrying that claim** — nothing here validates or restricts which +claims/roles a caller may assert about themselves. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **on top of transient external login, don't + add this controller at all.** Write a custom controller instead (derive it from this app's own + base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/ + `IAuthTransientRepository` directly and builds the claims/roles itself from trusted data — never + from caller input. This is exactly what shields the app: `hasAuthController` stays `false`, so + Nano's own claim-forging endpoint is never mapped in the first place. This is a real, load-bearing + pattern in this codebase, not a hypothetical — see `Api.Admin`'s `AccountsController` (deriving + its own `BaseAdminController`), which implements `login/microsoft`/`login/refresh`/`me` by hand + for exactly this reason. +- **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the + endpoint), but don't treat an internal-only app as automatically safe either — anything that lets + a caller assign its own JWT claims is worth a deliberate decision, not a default. +- **Persistent auth (Identity present) does not have this problem** — `!hasIdentity` in the gate + above means the transient endpoint is never mapped once Identity is configured, regardless of + `AuthController`/external login. This warning is specific to the transient-auth shape. +- If none of the above applies — persistent auth, or transient auth with no need for + server-computed claims beyond what the external provider itself asserts — the generic controller + below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -309,6 +352,11 @@ Console.Read(); - Show the user every file touched, grouped by concern (appsettings per environment, the controller, and — for the issuer app — Staging/Production CI + K8s), plus the external-login repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim — confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys — never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it diff --git a/.claude/skills/nano-add-data-provider/SKILL.md b/.claude/skills/nano-add-data-provider/SKILL.md index d4bb537d..8779a448 100644 --- a/.claude/skills/nano-add-data-provider/SKILL.md +++ b/.claude/skills/nano-add-data-provider/SKILL.md @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual — if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — the `DbContext`/`AddNanoData<...>` generic arguments must agree with it. diff --git a/.claude/skills/nano-add-eventing-provider/SKILL.md b/.claude/skills/nano-add-eventing-provider/SKILL.md index 1ffb70d3..9e154f8e 100644 --- a/.claude/skills/nano-add-eventing-provider/SKILL.md +++ b/.claude/skills/nano-add-eventing-provider/SKILL.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create — RabbitMQ credentials come ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table — if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call — unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/.claude/skills/nano-add-identity/SKILL.md b/.claude/skills/nano-add-identity/SKILL.md index d9f01463..478586e4 100644 --- a/.claude/skills/nano-add-identity/SKILL.md +++ b/.claude/skills/nano-add-identity/SKILL.md @@ -19,11 +19,32 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider — with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever the user actually names it) Three cases, not two: - **Already derives `BaseEntityUser`/`BaseEntityUser`** — Identity is already wired to it. Say so and stop (or confirm before adding a second user entity — unusual, but not @@ -38,15 +59,15 @@ way to log in. If the user actually wants login/JWT, that's a different skill. number, etc.) — if a name collides, **stop and ask the user** how to resolve it (rename the existing property, or drop it in favor of the built-in one); don't silently pick for them. - **Doesn't exist at all** — create fresh, as below. -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package — `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) — `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` — same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -71,12 +92,12 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones — read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides — unless step 2 already found an existing plain entity to +`BaseEntityUser` already provides — unless step 3 already found an existing plain entity to convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an - existing entity** (step 2), this is the *only* change to the class itself — just the base type; + existing entity** (step 3), this is the *only* change to the class itself — just the base type; every existing property and method stays. **If creating fresh**, add only the scalar properties the user actually asked for — `BaseEntityUser` already carries the identity fields (username, email, phone, etc.), don't redeclare them. @@ -85,7 +106,7 @@ convert, in which case its existing properties carry over as-is; don't ask for t `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` — it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing - entity** (step 2), convert its existing mapping file the same way — just the base class; + entity** (step 3), convert its existing mapping file the same way — just the base class; keep every custom `Configure(...)` statement already in it, still calling `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first rule as a normal mapping. @@ -141,5 +162,5 @@ leave that as a follow-up the user has to remember separately. log in yet — `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response — don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response — don't partially wire Identity while waiting on a prerequisite. diff --git a/.claude/skills/nano-add-public-exposure/SKILL.md b/.claude/skills/nano-add-public-exposure/SKILL.md index b997a3bb..f333eac3 100644 --- a/.claude/skills/nano-add-public-exposure/SKILL.md +++ b/.claude/skills/nano-add-public-exposure/SKILL.md @@ -21,10 +21,24 @@ time — it specifically requires this. Ask the user up front rather than assumi via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated — see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly — **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated — see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) — becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -132,10 +146,10 @@ group, so an app can be reachable under multiple domains without per-domain conf ## GitHub Actions -1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 4 above; never +1. **Workflow env vars** — `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -160,7 +174,11 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app — this skill diff --git a/.claude/skills/nano-add-storage-provider/SKILL.md b/.claude/skills/nano-add-storage-provider/SKILL.md index 3bc7d606..01ec11ec 100644 --- a/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/.claude/skills/nano-add-storage-provider/SKILL.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own — a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call — like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano diff --git a/.github/prompts/nano-add-api-client.prompt.md b/.github/prompts/nano-add-api-client.prompt.md index fb6770a9..d1ac8728 100644 --- a/.github/prompts/nano-add-api-client.prompt.md +++ b/.github/prompts/nano-add-api-client.prompt.md @@ -1,140 +1,69 @@ --- mode: agent -description: Wire a Nano Api Client into an application - defines a BaseApiClient subclass for calling another Nano application over HTTP, and its App:Apis configuration entry. Use when the user asks to call another Nano service/API, add an API client, or compose a Public API from internal services in a Nano API, Web, or Console application. +description: Scaffold the bare Api Client class a Nano application exposes to other Nano applications - the BaseApiClient/BaseIdentityApiClient subclass itself, in the owning service's {Name}.Models project. Use when a service needs a client class to exist before any custom endpoint can be built against it, or when Identity is added/removed and an existing client's base class needs to change. For adding a custom method backing a specific new endpoint, see nano-add-custom-endpoint's internal-service path instead. --- # Nano add API client -Wires a typed HTTP client for calling another Nano application into an existing Nano API, Web, or -Console application. Read `AGENTS.md`'s `### Api Clients` section first - it documents the built-in -method groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`), the custom-request attribute shapes, and -authentication forwarding in full; this skill does not repeat that, only how to wire a new client -into this specific app. - -**No registration call.** Every `BaseApiClient` subclass in the entry assembly whose class name -matches a key under `App:Apis` is auto-wired - but per AGENTS.md's own gotcha, a client that's -never actually injected anywhere doesn't get registered at all. Define the class, add the config, -then make sure something actually consumes it (a controller or worker constructor parameter) or -none of this takes effect. +Creates the bare typed HTTP client class a Nano application exposes to *other* applications - the +counterpart to `nano-add-api-client-configuration`, which wires an already-created client into a +*consumer*. This skill is the owning service's job, and it is boilerplate only: the class itself, +on the correct base type. It does not add custom methods - a custom method is one half of a +specific endpoint's contract (the other half being the controller action that backs it), and +scaffolding those two together is `nano-add-custom-endpoint`'s internal-service path, not +this skill's. Read AGENTS.md's `### Api Clients` section first - it documents the built-in method +groups (`.Entity`/`.Auth`/`.Audit`/`.Identity`) and the `{TargetName}.Models/Api/` location +convention in full; this skill does not repeat that, only how to apply it. + +If the user's request is actually about calling this client from some other app, not creating it, +that's `nano-add-api-client-configuration`'s job instead - point them there. If the request is +"add a custom method for this new endpoint," that's `nano-add-custom-endpoint`'s +internal-service path - point them there instead of doing it here. ## Before making any change, determine -1. **Which target application**, and where its `{TargetName}.Models` project (or published NuGet) - lives - that's where the client class and any custom request types belong (AGENTS.md: "the - client class and its custom request types live in the *owning* service's `{name}.Models/Api/` - project"). If the target is in the same solution, check how any other Api Client in this - project already references its target's `.Models` project (`ProjectReference` for a same- - solution/monorepo target, or a NuGet reference for a separate-repo target) and match that - convention - AGENTS.md explicitly allows either for this case, unlike Nano.Library itself. -2. **Does the target have persistent Identity?** Determines the base class: `BaseApiClient`/ - `BaseApiClient` (no Identity, or identity type doesn't matter to this client), or - `BaseIdentityApiClient` (target has Identity - unlocks the `.Identity` - method group). Check the target's own `Data:Identity` config / `BaseEntityUser`-derived entity - if you have access to its source; ask the user if not. -3. **Is a client with this class name already registered?** Check for an existing class matching - the intended name (the `App:Apis` key must exactly match it - AGENTS.md: "the only link between - config and DI"). Pick a name that doesn't collide. -4. **Console app?** Per AGENTS.md's `#### Authentication forwarding`: Console workers have no - inbound `HttpContext`, so they can't transparently forward a caller's JWT - a Console-hosted - client typically only calls `[AllowAnonymous]` endpoints on the target, or needs `LogInRoot` - configured if it must call authenticated ones. Ask which applies before adding `LogInRoot`. -5. **Custom endpoints needed beyond `.Entity`/`.Auth`/`.Audit`/`.Identity`?** If so, this also - means defining request types (see below) - confirm which endpoints on the target before - guessing at routes. +1. **Does a client class already exist for this application?** Check `{ThisApp}.Models/Api/` for + an existing `BaseApiClient`/`BaseIdentityApiClient` subclass. If one exists and this app's + Identity status hasn't changed, there's nothing for this skill to do - say so. +2. **Does this application have persistent Identity?** Determines the base class: + `BaseApiClient`/`BaseApiClient` (no Identity, or identity type doesn't matter to + callers), or `BaseIdentityApiClient` (this app has Identity - unlocks the + `.Identity` method group for every consumer). Check this app's own `Data:Identity` config / + `BaseEntityUser`-derived entity. + - **If a client already exists on the plain `BaseApiClient` base and this app has Identity + configured** (e.g. Identity was added, via `nano-add-identity`, *after* the client was first + created): that client **must be changed** to derive from + `BaseIdentityApiClient` instead - otherwise none of this app's + identity-management endpoints (sign-up, password, roles, claims, API keys) are reachable + through it. Don't leave it on the plain base class just because "add identity" wasn't the + request that triggered this particular change. +3. **What's the client's name?** Consumers reference it by exact class name (the `App:Apis` + dictionary key on their side must match it exactly) - pick something unambiguous and stable; + renaming it later breaks every consumer's config. ## Client class -`{TargetName}.Models/Api/{ClientName}.cs` (owning service's Models project, not this consuming -app): +`{ThisApp}.Models/Api/{ClientName}.cs`: ```csharp -// Bare pass-through +// Bare pass-through - no custom methods, relies entirely on .Entity/.Auth/.Audit public class MyApi(ApiClient apiClient) : BaseApiClient(apiClient); ``` ```csharp -// Identity-backed target +// Identity-backed - adds the .Identity method group for every consumer public class MyApi(ApiClient apiClient) : BaseIdentityApiClient(apiClient); ``` -Add custom methods only if step 5 applies - one method per custom request, calling -`this.InvokeAsync(request, cancellationToken)` (no response) or -`this.InvokeAsync(request, cancellationToken)` (typed response). - -## Custom requests (only if step 5 applies) - -`{TargetName}.Models/Api/Requests/{Name}Request.cs`, one action attribute -(`[GetAction]`/`[PostAction]`/etc.) naming the HTTP verb + relative route, per AGENTS.md's four -request shapes. Set `this.Controller` explicitly in the constructor unless the route naturally -matches the pluralized response type. **Define the route segment as a constant** in a `Consts` -class inside `{TargetName}.Models` and reference it from both this request's action attribute and -the target controller's `[Route(...)]` - per AGENTS.md, nothing else keeps the two sides in sync, -and Nano's own built-in requests avoid drift exactly this way. - -## appsettings.json (consuming app) - -Add the `App:Apis:{ClientClassName}` section to the base `appsettings.json` - the dictionary key -must be the exact class name from above: - -```json -"App": { - "Apis": { - "MyApi": { - "Host": "my-service", - "Root": "api", - "Port": 8080, - "UseSsl": false, - "Timeout": "00:00:30" - } - } -} -``` - -- `Host` matches the target's Kubernetes service name in Staging/Production (or the docker-compose - service name locally, if calling another app in the same compose network) - not sensitive, - stays in the base file. -- Include `HealthCheck: { "UnhealthyStatus": "Unhealthy" }` only if this app's own - `App:HealthCheck` is enabled (API/Web apps only) - same dead-config rule as every other - provider's health check. -- **`LogInRoot`** (`Username`/`Password`), if step 4 needs it: **this grants the caller a real, - full `administrator` identity** on the target - not a lesser scope, the same as any human root - login. That's exactly why it's worth using instead of just making the target endpoint - anonymous: an anonymous endpoint has no identity or audit trail at all, while a `LogInRoot` - call still flows through the target's normal authorization *and* shows up in its audit log as - root having acted - the right choice whenever the target also serves real authenticated - end-users, or attribution of machine-to-machine calls matters. But because it's full admin - access, the credential needs the same secret-handling rigor as anything else that powerful - - don't hardcode it in the base `appsettings.json`; set the real value only in - `appsettings.Development.json` locally, and source it from a Kubernetes secret + GitHub secret - in Staging/Production, the same pattern used for SQL passwords and JWT private keys elsewhere. - **Don't create a new secret for this app** - `LogInRoot` only works if its credentials match - the target's own `Jwt.RootLogin`, so this app must reference the *same* secret the target - creates (conventionally `auth-root-login-secret`, keys `root-login-username`/ - `root-login-password`), mapped into `App__Apis__{ClientName}__LogInRoot__Username`/`Password` - in `deployment.yaml` - never re-create or duplicate it with a new name. Don't confuse this with - `Jwt.RootLogin` - see AGENTS.md's explicit warning distinguishing the two; they're on different - apps, in different - directions. - -## Injecting the client - -Inject the client class directly into whatever consumes it - a controller or worker constructor -parameter: - -```csharp -public class MyController(ILogger logger, MyApi myApi) : BaseController(logger) -{ - // ... -} -``` - -This is the step that actually makes the `App:Apis` entry take effect - without it, per the -gotcha above, nothing gets registered even though the config exists. +Nothing else goes in this class as part of this skill - no custom methods, no custom request +types. Once the class exists, adding a custom method for a specific endpoint is +`nano-add-custom-endpoint`'s job, paired with the controller action it calls. ## After making the change -- Show the user every file touched, noting which project each lives in (owning service's - `.Models` vs. this consuming app). -- Confirm the client is actually injected somewhere - if the request was just "add the client" with - no specified consumer yet, say explicitly that nothing is wired up until it's referenced. -- If `LogInRoot` was added, restate the Staging/Production secret-handling requirement - don't let - a real credential sit in the base file. +- Show the user the file created (or the base-class change, if this was an Identity-driven + conversion) and confirm which project it lives in (this app's own `.Models`, not a consumer's). +- If the user's actual goal was consuming this (or another) client from a different application, + point them at `nano-add-api-client-configuration` instead. +- If the user's actual goal was adding a custom method for a specific endpoint, point them at + `nano-add-custom-endpoint`'s internal-service path instead - this skill only produces the + bare class. diff --git a/.github/prompts/nano-add-authentication-apikey.prompt.md b/.github/prompts/nano-add-authentication-apikey.prompt.md index 008f5713..fd91f79d 100644 --- a/.github/prompts/nano-add-authentication-apikey.prompt.md +++ b/.github/prompts/nano-add-authentication-apikey.prompt.md @@ -23,10 +23,23 @@ identity store on every single request, with no token step at all. 1. **Is Identity already configured?** Check the base `appsettings.json` for `Data:Identity`, and `Program.cs`/the project for an `.AddNanoData<...>()` + identity entity. API-key auth is an identity-store feature (AGENTS.md), not usable without it - if missing, stop and point the - user at `nano-add-identity` first. -2. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. + user at `nano-add-identity` first, which will itself check whether this app is meant to be a + Public API (Identity is an internal-service-only feature - see that skill's own warning) before + adding anything. +2. **If Identity already existed before step 1's referral would have caught it, check public + exposure directly here too - don't rely solely on the referral.** Look for + `.kubernetes/httproute-80.yaml`/`httproute-443.yaml` (the same files `nano-add-identity` and + `nano-add-public-exposure` check). Identity may have been added in an earlier session, before + this check existed, or by a path that never ran `nano-add-identity`'s gate - so its own + forward-looking check can't be assumed to have already happened. If either file is present, + **stop before touching anything** and confirm with the user this is intentional: layering + API-key auth onto an already-publicly-exposed app that also has `BaseEntityUserController` + means raw `X-Api-Key` values are now checked directly against requests from the open internet, + not just from another service that already exchanged one for a JWT (see AGENTS.md's own note + on that intended flow, under `#### Authentication`). +3. **Is API-key auth already configured?** Check for `Data:Identity:ApiKey:Secret` already set. If so, say so and stop. -3. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` +4. **Is JWT authentication already configured on this app?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. - **Not configured** - this app will end up in **pure API-key mode**: no `AuthController`, no `Jwt` config, `X-Api-Key` is the only credential, checked on every request. Don't add a @@ -38,7 +51,7 @@ identity store on every single request, with no token step at all. becomes reachable at `/auth/login/apikey` the moment this skill sets the config - tell the user this new endpoint just appeared, it's a real behavior change on an app that may already have callers, not just an implementation detail. -4. **Application type.** No controller involved either way in pure mode; in the JWT-paired case +5. **Application type.** No controller involved either way in pure mode; in the JWT-paired case the controller already exists (added by `nano-add-authentication-jwt`). Nothing API/Web-specific for this skill to gate on beyond that. @@ -71,7 +84,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba Apply it in the `Kubernetes Deploy` step, same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other secret - before `deployment.yaml`/`stateful-set.yaml`. Unlike `auth-jwt-secret.yaml`, this one is per-app, not shared across services - every app - with API-key auth creates and applies its own. + with API-key auth creates and applies its own. Also add `.kubernetes\auth-api-key-secret.yaml + = .kubernetes\auth-api-key-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block + (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in + Visual Studio's Solution Explorer otherwise. 3. **`.kubernetes/deployment.yaml`** env entry: ```yaml - name: Data__Identity__ApiKey__Secret @@ -88,7 +104,10 @@ testing convenience, set one in `appsettings.Development.json` instead of the ba - Show the user every file touched. - State plainly which mode this app ended up in - pure API-key (no controller, no login step) or - paired with existing JWT (`/auth/login/apikey` now live) - from step 3. Don't leave this + paired with existing JWT (`/auth/login/apikey` now live) - from step 4. Don't leave this implicit; it's the one thing genuinely worth double-checking landed as intended. +- If step 2 found this app already publicly exposed and the user confirmed proceeding anyway, + restate the specific risk one more time - raw `X-Api-Key` values now checked directly against + internet traffic - rather than letting the earlier confirmation be the only mention of it. - If step 1 stopped the skill early for a missing Identity prerequisite, that's the whole response. diff --git a/.github/prompts/nano-add-authentication-jwt.prompt.md b/.github/prompts/nano-add-authentication-jwt.prompt.md index a87c0d62..ef5dd7a5 100644 --- a/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -28,6 +28,11 @@ Figure out which one applies before touching anything - steps 1–5 below are ho layered with API-key auth by running `nano-add-authentication-apikey` before or after this skill - see step 6. +These two aren't the only combination - `Jwt.ExternalLogins` can also layer on top of already-configured +Identity (e.g. "sign in with Google" but the account is still persistent, not transient). See +AGENTS.md's sub-repository table for exactly how `AuthExternalRepositoryAggregator` resolves which +repository backs a given external login in that case - not repeated here. + ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. @@ -43,10 +48,20 @@ step 6. is already configured. - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the - `Jwt` config and controller below. + `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) + is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a genuine Public API has no `IRepository` + of its own, so it can't have Identity configured in the first place. If this app is meant to + be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own + warning on this, and point the user at composing through the owning internal service's Api + Client instead. - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ - Google/Microsoft, or a custom provider per AGENTS.md's `##### Custom external provider`) - - ask which, and whether a custom provider implementation is needed, before proceeding. + Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and + whether a custom provider implementation is needed, before proceeding. **Also ask whether + this app needs to assert its own server-computed claims/roles on top of the external login** + (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before + scaffolding a generic `AuthController`; adding one unconditionally here can open a + caller-controlled claim-injection endpoint. - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for @@ -122,6 +137,39 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) +**Stop and check this before scaffolding it - it's not always safe to add.** Nano auto-maps the +built-in transient external-login endpoint (`/auth/login/external/{provider}/transient`) whenever +*any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - +see `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked +by type scan, not by whether this specific controller is the one deriving it. That endpoint binds +the request body straight into `LogInExternal` and merges its `TransientClaims`/ +`TransientRoles` **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`). Concretely: once this app is in transient auth +(step 2) with any external login provider configured, adding this controller means **any +anonymous caller can post `{"transientClaims": {"IsAdmin": "true"}}` to that endpoint and receive +back a validly-signed token carrying that claim** - nothing here validates or restricts which +claims/roles a caller may assert about themselves. + +- **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal + role, anything not meant to be caller-assignable) **on top of transient external login, don't + add this controller at all.** Write a custom controller instead (derive it from this app's own + base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/ + `IAuthTransientRepository` directly and builds the claims/roles itself from trusted data - never + from caller input. This is exactly what shields the app: `hasAuthController` stays `false`, so + Nano's own claim-forging endpoint is never mapped in the first place. This is a real, load-bearing + pattern in this codebase, not a hypothetical - see `Api.Admin`'s `AccountsController` (deriving + its own `BaseAdminController`), which implements `login/microsoft`/`login/refresh`/`me` by hand + for exactly this reason. +- **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the + endpoint), but don't treat an internal-only app as automatically safe either - anything that lets + a caller assign its own JWT claims is worth a deliberate decision, not a default. +- **Persistent auth (Identity present) does not have this problem** - `!hasIdentity` in the gate + above means the transient endpoint is never mapped once Identity is configured, regardless of + `AuthController`/external login. This warning is specific to the transient-auth shape. +- If none of the above applies - persistent auth, or transient auth with no need for + server-computed claims beyond what the external provider itself asserts - the generic controller + below is fine as-is. + `Controllers/AuthController.cs`, main app project: ```csharp @@ -133,6 +181,75 @@ Nothing to implement - every endpoint the current config enables (per AGENTS.md' table) is provided. For a non-`Guid` identity type, use `BaseAuthController` and `IAuthRepository` to match (same rule as every other controller in this ecosystem). +## External Login (`Jwt.ExternalLogins`) + +Only relevant if step 2 found external login in play - either the transient case, or the hybrid +persistent-plus-external-login case noted above. Two genuinely different kinds of work, not one: + +**Built-in provider (Facebook / Google / Microsoft) - pure config, no code.** Add the matching +block under `Jwt.ExternalLogins` in the base `appsettings.json`, per AGENTS.md's `##### Configuration` +table (`Facebook.AppId`/`.AppSecret`/`.Scopes`, `Google.ClientId`/`.ClientSecret`/`.Scopes`, +`Microsoft.TenantId`/`.ClientId`/`.ClientSecret`/`.Scopes`). Treat `AppSecret`/`ClientSecret` as +real secrets, the same class of value as the JWT keys above - `null` in the base file, a real +value only where it's actually safe to have one. + +- **Microsoft has its own skill, `nano-add-authentication-microsoft`** - it's the one built-in + provider whose credentials can be scripted (an Entra ID app registration via the Azure CLI), so + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention (see + `Nano.Lessons/Api.Auth.External.Microsoft`). If the request names Microsoft specifically, use + that skill instead of configuring `Jwt.ExternalLogins.Microsoft` by hand here. +- **Facebook/Google have no such convention.** Their credentials are created by hand through each + provider's own developer console - don't invent a Kubernetes/GitHub-secret pattern for them; ask + the user how they want it stored for Staging/Production rather than assuming one exists. + +**Custom provider - real code, no config entry.** Per AGENTS.md's `##### Custom external provider`, +this is auto-discovered by type, not registered via `Jwt.ExternalLogins` config the way built-in +providers are - there's no appsettings.json entry for it at all. + +1. **`TFlow`.** `ImplicitFlow` or `AuthCodeFlow` (both derive `BaseAuthFlow`) - pick whichever + matches the provider's actual OAuth flow; ask if unclear rather than guessing. Derive a custom + `BaseAuthFlow` subclass instead only if the provider's flow doesn't fit either built-in shape. +2. **The class**, conventionally `Auth/{Provider}ExternalRepository.cs` in the application + project (discovery is by type, so the location isn't enforced): + ```csharp + public class MyExternalRepository() : BaseAuthExternalRepository("MyProvider") + { + public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) + { + // call the external provider, map its response to ExternalAuthenticationData + return new ExternalAuthenticationData + { + Id = "external-id", + Username = "MyUser", + EmailAddress = "user@domain.com", + Name = "My User", + ExternalToken = new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" } + }; + } + + public override async Task AuthenticateRefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + // refresh against the external provider + return new ExternalAuthenticationToken { Name = this.ProviderName, Token = "token", RefreshToken = "refresh-token" }; + } + } + ``` + The constructor's string argument (`"MyProvider"` above) is `ProviderName` - this is what + `AuthExternalRepositoryAggregator` resolves against, and what appears in the + `/auth/login/external/{providerName}/...` route, so ask the user what they want it called + rather than defaulting to the class name. +3. **Whatever credentials/endpoint the provider itself needs** (API key, base URL, etc.) - these + are this custom repository's own concern, not `Jwt.ExternalLogins`'s. Add them as an options + class bound from whatever config section makes sense for this provider (same pattern as any + other custom service in this codebase), then inject it into the repository's constructor. Don't + try to route them through `Jwt.ExternalLogins` - that section is exclusively for the three + built-in providers. + +Either way, the actual login endpoint this exposes is +`/auth/login/external/{providerName}/transient` when Identity isn't configured, or the +persistent equivalent per AGENTS.md's sub-repository table when it is - this skill doesn't scaffold +that call site, only the repository/config that backs it. + ## Kubernetes / GitHub Actions (Staging/Production) - issuer app only Only the app that **issues** tokens does this. A validator-only app does **not** create or @@ -158,13 +275,17 @@ it for any app but the issuer. jwt-public-key: %AUTH_JWT_PUBLIC_KEY% jwt-private-key: %AUTH_JWT_PRIVATE_KEY% ``` - Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. + Apply it in the `Kubernetes Deploy` step, before `deployment.yaml`/`stateful-set.yaml`. Also + add `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files + under `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. -## Kubernetes - every app (issuer and validator) +## Kubernetes - deployment.yaml -Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - issuer apps map both -keys, validator-only apps map `PublicKey` only: +Reference the secret in `.kubernetes/deployment.yaml`'s container `env` - **the two app types get +different entries here, not the same block with one line dropped**: +Issuer app (both keys): ```yaml - name: App__Authentication__Jwt__PublicKey valueFrom: @@ -178,7 +299,14 @@ keys, validator-only apps map `PublicKey` only: key: jwt-private-key ``` -Drop the `PrivateKey` entry entirely for a validator-only app. +Validator-only app (`PublicKey` only - no `PrivateKey` entry at all): +```yaml +- name: App__Authentication__Jwt__PublicKey + valueFrom: + secretKeyRef: + name: auth-jwt-secret + key: jwt-public-key +``` ## API-key authentication @@ -222,7 +350,13 @@ Console.Read(); ## After making the change - Show the user every file touched, grouped by concern (appsettings per environment, the - controller, and - for the issuer app - Staging/Production CI + K8s). + controller, and - for the issuer app - Staging/Production CI + K8s), plus the external-login + repository class if one was scaffolded. +- If this is transient auth with external login and a generic `AuthController` was added, restate + explicitly that `/auth/login/external/{provider}/transient` is now live and accepts + caller-supplied `TransientClaims`/`TransientRoles` verbatim - confirm that's actually acceptable + for this app before considering the task done. If a custom controller was used instead specifically + to avoid this, say so, and confirm it does **not** derive `BaseAuthController` anywhere in the app. - Point them at the snippet above for generating real Staging/Production keys - never the hardcoded Development pair. - If they want to change the Development key pair from the shared default, warn explicitly: it diff --git a/.github/prompts/nano-add-authentication-microsoft.prompt.md b/.github/prompts/nano-add-authentication-microsoft.prompt.md index 79352245..75b4db10 100644 --- a/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -1,9 +1,8 @@ --- mode: agent -description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application - adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application - requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). +description: Configure Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) on a Nano.Library-based API/Web application — adds the config, and (for Staging/Production) a self-provisioning, self-rotating Azure AD app registration wired into the GitHub Actions workflow plus a Kubernetes secret. Use when the user asks to add "Sign in with Microsoft", Entra ID, or Azure AD external login to a Nano API or Web application — requires nano-add-authentication-jwt already configured (Jwt.ExternalLogins lives under that same config), and does not apply to Google/Facebook, which have no CLI-scriptable credential setup and stay manually configured (see AGENTS.md's Authentication section). --- - # Nano add Microsoft authentication Configures the built-in `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) on an diff --git a/.github/prompts/nano-add-azure-managed-identity.prompt.md b/.github/prompts/nano-add-azure-managed-identity.prompt.md index 053da6d8..75c19e68 100644 --- a/.github/prompts/nano-add-azure-managed-identity.prompt.md +++ b/.github/prompts/nano-add-azure-managed-identity.prompt.md @@ -38,6 +38,9 @@ wired before their Staging/Production sections apply - this skill is what makes annotations: azure.workload.identity/client-id: %IDENTITY_CLIENT_ID% ``` + Also add `.kubernetes\service-account.yaml = .kubernetes\service-account.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. 2. **`.kubernetes/deployment.yaml`/`cronjob.yaml`**: add the workload-identity label to the pod template's metadata and reference the service account in the pod spec: ```yaml diff --git a/.github/prompts/nano-add-data-provider.prompt.md b/.github/prompts/nano-add-data-provider.prompt.md index ef032dc8..767788bc 100644 --- a/.github/prompts/nano-add-data-provider.prompt.md +++ b/.github/prompts/nano-add-data-provider.prompt.md @@ -14,20 +14,26 @@ without breaking what's already there. ## Before making any change, determine -1. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Data provider is *allowed* there (not the + hard block Identity/Auth are), but it's a deviation from that lean-façade design, not the + default. If this app is a Public API, confirm with the user that persistence genuinely belongs + on this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** One of `MySql`, `PostgreSQL`, `SqlServer`, `SqLite`, `InMemory` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a data provider already registered?** Check `Program.cs` for an existing +3. **Is a data provider already registered?** Check `Program.cs` for an existing `.AddNanoData<...>()` call. Unlike logging, a second data provider isn't automatically wrong (multi-context setups exist), but it's unusual - if one is already registered, confirm with the user whether they want to *replace* it (single-context swap) or genuinely add a second `DbContext` before proceeding either way. -3. **Is a package reference even needed?** Same check as the logging skill: look for a +4. **Is a package reference even needed?** Same check as the logging skill: look for a `PackageReference` to `NanoCore` or `Nano.All` (identical, see AGENTS.md) on the application project or a `.Models` project it reaches via `ProjectReference`. If found, skip the package step. Otherwise add `` to the **application project** (never `.Models`), matching the version of the project's existing Nano application-type package. Never add a `ProjectReference` to Nano.Library source. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - the `DbContext`/`AddNanoData<...>` generic arguments must agree with it. @@ -88,10 +94,13 @@ In `appsettings.Development.json`, add: Use `host.docker.internal` as the host in the local connection string, not the docker-compose service name - `BaseDbContextFactory` specifically rewrites `host.docker.internal` → `localhost` in `Development` so `dotnet ef` commands from a local shell still work; the docker-compose -service name wouldn't resolve outside the compose network at all. If the project's -`appsettings.Development.json` already has other providers' connection strings commented out, -add the new one active and leave/add the others commented alongside it, matching that structure -rather than replacing it. +service name wouldn't resolve outside the compose network at all. + +⚠ Add only the chosen provider's connection string, active. Don't add the other providers' +connection strings as commented-out alternatives - a project commits to exactly one provider, and +commented dead alternatives for providers it doesn't use are clutter, not documentation. If a +prior, different provider's `ConnectionString` is already present (replacing an existing +provider), remove it rather than commenting it out. ## Initial migration @@ -108,9 +117,11 @@ entity-scaffold skill's same rule). ## docker-compose.yml (local Development) Add a `database` service to `.docker/docker-compose.yml`, and add `depends_on: [database]` to -the app's own service if not already present. Use the image/env matching the chosen provider - -if the file already has other providers' `database` blocks commented out, activate the matching -one and leave the others commented rather than deleting them: +the app's own service if not already present. Add only the one block matching the chosen +provider - not the other two as commented-out alternatives; a project uses one data provider, and +dead blocks for providers it doesn't use are clutter to maintain, not documentation. If a prior, +different provider's `database` block is already present (replacing an existing provider), remove +it rather than commenting it out. ```yaml # MySql @@ -154,9 +165,9 @@ server container. ## SqLite (Kubernetes persistent volume, not a migration CI step) -`SqLite` needs no `SQL_TYPE`-style migration step and no Managed Identity - it's a local file, -not a network database - but unlike `InMemory` it does need K8s storage so the file survives pod -restarts, and it deviates from the base-vs-Development split used elsewhere in this skill: +`SqLite` needs no migration CI step and no Managed Identity - it's a local file, not a network +database - but unlike `InMemory` it does need K8s storage so the file survives pod restarts, and +it deviates from the base-vs-Development split used elsewhere in this skill: - **`appsettings.json` (base, not just Development)**: `"StartupAction": "Migrate"` and `"ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite"` go directly in the base file, the @@ -228,7 +239,10 @@ restarts, and it deviates from the base-vs-Development split used elsewhere in t by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern), before `stateful-set.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one - per pod automatically. + per pod automatically. Also add `.kubernetes\data-storageclass.yaml = .kubernetes\data-storageclass.yaml` + and `.kubernetes\service-headless.yaml = .kubernetes\service-headless.yaml` to `{name}.sln`'s + `.kubernetes` `SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under + `.kubernetes/` don't show up in Visual Studio's Solution Explorer otherwise. - No `docker-compose.yml` `database` service, no `auth-sql-secret.yaml`, no `configmap.yaml` change - none of the Staging/Production section below applies to SqLite. @@ -252,21 +266,28 @@ Provisioning that server is out of this skill's scope. 1. **Workflow env vars** - add alongside the existing ones: ```yaml - SQL_TYPE: SQL_AUTH_TYPE: Azure SQL_NAME: AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` -2. **Migration step** - add one of the three provider-specific steps below, placed after - `Managed Identity` and before `Kubernetes Deploy` in the workflow. Each: resolves the Azure + ⚠ No `SQL_TYPE` variable, and no `if:` guard on the migration step below. A `SQL_TYPE`-style + runtime switch only earns its keep when an app genuinely needs to pick its provider at deploy + time - that's not this skill's job; the app has exactly one data provider, chosen once, here. + Add only the one migration step matching that provider, unconditionally. Don't add the other + two providers' steps as dormant `if:`-guarded alternatives - unreachable steps (and the + `AZURE_GROUP_LOGS` env var the SQL Server one alone needs) are clutter to maintain, not + documentation, and a workflow file is not the place to leave every road not taken. If this is + *replacing* an existing provider, remove that provider's migration step (and any env vars only + it needed) rather than leaving it disabled alongside the new one. +2. **Migration step** - add the one step below matching the chosen provider, placed after + `Managed Identity` and before `Kubernetes Deploy` in the workflow. It resolves the Azure server, runs `dotnet ef database update` using an elevated/admin credential, then grants the app's own Managed Identity minimal (`SELECT, INSERT, UPDATE, DELETE`) permissions and builds the final passwordless `SQL_CONNECTIONSTRING` the app itself will use at runtime: ```yaml - name: MySQL Database Migration - if: env.SQL_TYPE == 'mysql' shell: pwsh run: | $env:SQL_HOST = az mysql flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -303,7 +324,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: PostgreSQL Database Migration - if: env.SQL_TYPE == 'postgresql' shell: pwsh run: | $env:SQL_HOST = az postgres flexible-server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -359,7 +379,6 @@ Provisioning that server is out of this skill's scope. ```yaml - name: SQL Server Create Database - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_SERVICE_OBJECTIVE = "GP_Gen5_2"; @@ -435,12 +454,11 @@ Provisioning that server is out of this skill's scope. This step is idempotent (`if (-not $env:SQL_DB_EXISTS)`) - safe to always include, it only acts the first time. It needs `AZURE_GROUP_LOGS: ${{ vars.AZURE_RESOURCE_GROUP_LOGS }}` added - to the workflow env block alongside `AZURE_GROUP_DATABASE` if not already present (diagnostics - and alerts attach to the Log Analytics workspace/action group there). + to the workflow env block alongside `AZURE_GROUP_DATABASE` - but only when `SqlServer` is the + chosen provider; no other provider's step uses `AZURE_GROUP_LOGS`, so don't add it otherwise. ```yaml - name: SQL Server Database Migration - if: env.SQL_TYPE == 'sqlserver' shell: pwsh run: | $env:SQL_HOST = az sql server list -g $env:AZURE_GROUP_DATABASE --query [0].fullyQualifiedDomainName -o tsv; @@ -479,7 +497,10 @@ Provisioning that server is out of this skill's scope. ``` Apply it in the `Kubernetes Deploy` step (same `Get-Content | ExpandEnvironmentVariables | Set-Content .tmp.yaml` + `kubectl apply` pattern every other manifest in the workflow uses), - before the app's own `deployment.yaml` is applied. + before the app's own `deployment.yaml` is applied. Also add `.kubernetes\auth-sql-secret.yaml + = .kubernetes\auth-sql-secret.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 4. **ConfigMap** - add `Data__AuthenticationType: %SQL_AUTH_TYPE%` to `.kubernetes/configmap.yaml`. This is what actually makes the live environment use `Azure` auth - the base `appsettings.json` stays `Credentials` always (see above); this env var overrides it at diff --git a/.github/prompts/nano-add-eventing-provider.prompt.md b/.github/prompts/nano-add-eventing-provider.prompt.md index 3671057c..dbdbee6e 100644 --- a/.github/prompts/nano-add-eventing-provider.prompt.md +++ b/.github/prompts/nano-add-eventing-provider.prompt.md @@ -17,15 +17,21 @@ Identity pairing, and no per-app secret to create - RabbitMQ credentials come fr ## Before making any change, determine -1. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - an Eventing provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that publishing/subscribing to events genuinely belongs on + this app rather than on an internal service reached via Api Client, before proceeding. +2. **Which provider.** Currently only `RabbitMq` (see AGENTS.md's provider table - if the user names something else, check whether a custom provider already exists in the project first, per AGENTS.md's `#### Custom eventing provider` section). -2. **Is an eventing provider already registered?** Check `Program.cs` for an existing +3. **Is an eventing provider already registered?** Check `Program.cs` for an existing `.AddNanoEventing<...>()` call - unlike Data, there's no supported multi-provider case here (AGENTS.md: a provider's `Configure` must itself register the single `IEventing` implementation). If one is already registered, treat this as a replace and say so, the same as the logging skill. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -110,9 +116,9 @@ nullable, so it doesn't change behavior for a controller that never ends up usin ## Staging/Production (Kubernetes) No CI step and no per-app secret to create - RabbitMQ is a **pre-existing, shared, cluster-wide** -broker, referenced by a secret (`rabbitmq-default-user`) that already exists in the cluster -before this app is ever deployed. Don't create a new secret or add a provisioning workflow step; -just wire the reference: +broker, referenced by a secret (`rabbitmq-default-user`) provisioned cluster-wide by the +infrastructure repo, not by this skill or this app. Don't create a new secret or add a +provisioning workflow step; just wire the reference: Add to `.kubernetes/deployment.yaml`'s container `env`: @@ -139,10 +145,6 @@ Add to `.kubernetes/deployment.yaml`'s container `env`: key: password ``` -If `rabbitmq-default-user` doesn't exist in the target cluster yet, that's a one-time, -cluster-level provisioning concern - tell the user rather than inventing a new secret name or a -provisioning step for it. - ## After making the change - Show the user the modified `Program.cs` lines, the `appsettings.json` additions (base + diff --git a/.github/prompts/nano-add-identity.prompt.md b/.github/prompts/nano-add-identity.prompt.md index 488fca95..2be816e3 100644 --- a/.github/prompts/nano-add-identity.prompt.md +++ b/.github/prompts/nano-add-identity.prompt.md @@ -19,23 +19,55 @@ way to log in. If the user actually wants login/JWT, that's a different skill. ## Before making any change, determine -1. **Is a Data provider already registered?** Check `Program.cs` for `.AddNanoData()`. Identity is layered onto the existing `DbContext`, not a separate package or provider - with none registered, stop and tell the user a Data provider needs to be added first (see `nano-add-data-provider`). -2. **Is Identity already configured?** Check the base `appsettings.json` for a `Data:Identity` - section, and the project for an entity deriving `BaseEntityUser`/`BaseEntityUser`. - If both already exist, say so and stop (or confirm with the user before adding a second user - entity - unusual, but not something to do silently). -3. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a +3. **Does an entity with the intended name already exist?** (conventionally `User`, but whatever + the user actually names it) Three cases, not two: + - **Already derives `BaseEntityUser`/`BaseEntityUser`** - Identity is already wired + to it. Say so and stop (or confirm before adding a second user entity - unusual, but not + something to do silently). + - **Already exists, but derives plain `BaseEntity`/`BaseEntity`** (or one of the + narrower capability bases) - this app already has its own reason for this entity to exist, + independent of Identity. **Don't create a second, conflicting class.** Convert the existing + one in place instead: change its base class to `BaseEntityUser`/`BaseEntityUser`, + and carry every existing custom property and any existing controller's custom actions over + unchanged - this is an addition to what's there, not a replacement. First check its existing + properties against what `BaseEntityUser` already provides (username, email address, phone + number, etc.) - if a name collides, **stop and ask the user** how to resolve it (rename the + existing property, or drop it in favor of the built-in one); don't silently pick for them. + - **Doesn't exist at all** - create fresh, as below. +4. **No package reference needed.** Unlike the other add-provider skills, Identity isn't a separate NuGet package - `BaseEntityUser`, `BaseDbContext`, `IIdentityRepository`, and `BaseEntityUserController` all ship as part of `Nano.Data`/`Nano.App.Api` themselves, so whichever Data provider package is already referenced already carries them. Nothing to add here. -4. **Entity identity type.** If entities already exist in the project, match their `TIdentity` +5. **Entity identity type.** If entities already exist in the project, match their `TIdentity` (see the entity-scaffold skill's identity-type step) - `BaseEntityUser` and `IIdentityRepository` must agree with it. -5. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or +6. **Application type.** Check `Program.cs` for `NanoApiApplication`, `NanoWebApplication`, or `NanoConsoleApplication` - same split as the entity-scaffold skill: API/Web get the full entity/mapping/criteria/controller set below; Console gets only the entity and mapping (no HTTP surface to route identity actions through), unless the user explicitly wants to drive @@ -60,38 +92,69 @@ This is the entity-scaffold skill's file set, with identity-specific base classe the plain ones - read that skill first for the file-location/project-layout rules (split `.Models` project vs. single-project), which apply unchanged here. Ask the user for the entity name if not given (conventionally `User`) and any additional properties beyond what -`BaseEntityUser` already provides. +`BaseEntityUser` already provides - unless step 3 already found an existing plain entity to +convert, in which case its existing properties carry over as-is; don't ask for them again. - **Data model** (`Data/.cs`, or the `.Models` project in a split layout): derive from - `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. Add only the scalar - properties the user actually asked for - `BaseEntityUser` already carries the identity - fields (username, email, phone, etc.), don't redeclare them. + `BaseEntityUser`/`BaseEntityUser` instead of `BaseEntity`. **If converting an + existing entity** (step 3), this is the *only* change to the class itself - just the base type; + every existing property and method stays. **If creating fresh**, add only the scalar properties + the user actually asked for - `BaseEntityUser` already carries the identity fields (username, + email, phone, etc.), don't redeclare them. - **Mapping** (`Data/Mappings/Mapping.cs`, main app project always): derive from `BaseEntityUserMapping`/`` (namespace `Nano.Data.Mappings.Identity`) instead of `BaseEntityMapping` - it additionally configures the required 1:1 relationship to the underlying `IdentityUser` row and an - `IsActive` query filter, per AGENTS.md's Data Mappings table. Same - `base.Configure(builder)`-first rule as a normal mapping. + `IsActive` query filter, per AGENTS.md's Data Mappings table. **If converting an existing + entity** (step 3), convert its existing mapping file the same way - just the base class; + keep every custom `Configure(...)` statement already in it, still calling + `base.Configure(builder)` first. **If creating fresh**, same `base.Configure(builder)`-first + rule as a normal mapping. - **Query criteria** (API/Web only): exactly as the entity-scaffold skill's File 3 - nothing identity-specific here. - **Controller** (API/Web only, `Controllers/sController.cs`): derive from `BaseEntityUserController`/`` (namespace `Nano.App.Api.Controllers`) instead of `BaseEntityController<...>`, and take an additional constructor dependency, `IIdentityRepository`/`IIdentityRepository` (namespace - `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`: + `Nano.Data.Abstractions.Identity`), required, placed **after** `eventing`. **If this entity + already had a controller with custom actions**, keep every one of them - this change is the + base class and the added constructor dependency, nothing else. + + ⚠ **`BaseEntityUserController` does *not* use the single-optional-`IEventing?`-parameter + pattern** the plain entity controllers (and this skill's own description above) might suggest. + It exposes **two distinct constructor overloads** instead - one with no eventing parameter at + all, one with a **required, non-nullable** `IEventing eventing`: ```csharp - public class UsersController(ILogger logger, IRepository repository, IEventing? eventing, IIdentityRepository identityRepository) + // No eventing provider registered in this project + public class UsersController(ILogger logger, IRepository repository, IIdentityRepository identityRepository) + : BaseEntityUserController(logger, repository, identityRepository); + + // An eventing provider IS registered - eventing is required here, not IEventing? + public class UsersController(ILogger logger, IRepository repository, IEventing eventing, IIdentityRepository identityRepository) : BaseEntityUserController(logger, repository, eventing, identityRepository); ``` - Same `IEventing? eventing` check as the entity-scaffold skill's controller step: include it - only if the project has an eventing provider registered (check `Program.cs` for - `.AddNanoEventing<...>()`), otherwise drop the parameter and the corresponding base-constructor - argument entirely. + Check `Program.cs` for `.AddNanoEventing<...>()` and pick the matching overload - don't write + `IEventing? eventing` here and pass it positionally into the `eventing` slot: that's a nullable + reference passed to a non-nullable parameter, which is a compile **error** (not just a warning) + under this solution's `TreatWarningsAsErrors`. If no provider is registered, omit the parameter + entirely (first overload) rather than passing `null`. + This adds the identity-management endpoints from AGENTS.md's `#### Identity user controller` table (sign-up, password, roles, claims, API keys, etc.) on top of standard CRUD. Endpoints that don't match the current configuration (e.g. API-key management when API-key auth isn't enabled) aren't registered at all - nothing further to do for those until that's added. +## Api Client side + +If this application exposes an Api Client for other apps to consume (`nano-add-api-client`), +and it was previously a plain `BaseApiClient`/`BaseApiClient`, **it must now be +changed to derive from `BaseIdentityApiClient`** (`TUser` = this `User` entity) +- that's what unlocks the `.Identity` method group (sign-up, password, roles, claims, API keys, +etc.) for every consumer of this client. A client left on the plain base class after Identity is +added has no way to expose any of the endpoints this skill just enabled. Check for an existing +client class in `{ThisApp}.Models/Api/` and update its base class as part of this change - don't +leave that as a follow-up the user has to remember separately. + ## After making the change - Show the user every file touched. @@ -99,5 +162,5 @@ name if not given (conventionally `User`) and any additional properties beyond w log in yet - `nano-add-authentication-jwt` (JWT) or `nano-add-authentication-apikey` (API key, works standalone without JWT) are separate skills, needed before any of the `identity`-role endpoints are reachable by an actual caller. -- If step 1 or 2 stopped the skill early, that's the whole response - don't partially wire +- If step 1, 2, or 3 stopped the skill early, that's the whole response - don't partially wire Identity while waiting on a prerequisite. diff --git a/.github/prompts/nano-add-logging-provider.prompt.md b/.github/prompts/nano-add-logging-provider.prompt.md index 2c5f2bf8..f3010507 100644 --- a/.github/prompts/nano-add-logging-provider.prompt.md +++ b/.github/prompts/nano-add-logging-provider.prompt.md @@ -40,9 +40,13 @@ it correctly to an existing project without breaking what's already there. ## Program.cs -Add the `using`s and registration call AGENTS.md's `### Registration` section shows, inside the -**existing** `.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call -if one already exists. +Add the registration call AGENTS.md's `### Registration` section shows, inside the **existing** +`.ConfigureServices(...)` lambda - don't create a second `.ConfigureServices` call if one already +exists. This needs **two** `using`s, not one - `AddNanoLogging()` itself lives in +`Nano.Logging.Extensions`, a different namespace than `TProvider`, which lives in the specific +provider package's own namespace (e.g. `Nano.Logging.Serilog` for `SerilogProvider`). Add both; +forgetting `Nano.Logging.Extensions` is an easy miss since AGENTS.md's registration snippet +doesn't spell out `using`s at all. - If the existing lambda parameter is the discard placeholder `_` (e.g. `.ConfigureServices(_ => { // Add your services here. })`, the standard blank-app boilerplate), rename it to `x` and diff --git a/.github/prompts/nano-add-metrics.prompt.md b/.github/prompts/nano-add-metrics.prompt.md index 24f7ad83..069ccea4 100644 --- a/.github/prompts/nano-add-metrics.prompt.md +++ b/.github/prompts/nano-add-metrics.prompt.md @@ -23,10 +23,14 @@ direction. Enable it on its own; don't add Health Checks "because Metrics needs whether `.kubernetes/service-monitor.yaml` already exists - same "don't leave it half-wired" concern as Health Checks, though less severe here since nothing actively breaks without the `ServiceMonitor` (Prometheus just won't discover the endpoint to scrape it). -3. **Cluster has the Prometheus Operator / `ServiceMonitor` CRD available?** The K8s manifest - below uses `apiVersion: azmonitoring.coreos.com/v1` - if the target cluster doesn't have that - CRD installed, applying it will fail. This is a cluster-level prerequisite outside this skill's - scope; ask if unsure rather than assuming it's there. +3. **`azmonitoring.coreos.com/v1`, not `monitoring.coreos.com/v1`.** The K8s manifest below + deliberately targets **Azure Managed Prometheus**'s `ServiceMonitor` CRD group - the AKS add-on + Nano's own `Nano.App.Api` README documents this against ("these metrics can be scraped by + Azure Managed Prometheus and visualized in Grafana dashboards") - not the community Prometheus + Operator's CRD (`monitoring.coreos.com/v1`) that generic Kubernetes/Prometheus docs assume. + Every app in this solution targets AKS, so this isn't a cluster-dependent uncertainty to flag - + it's the correct, fixed `apiVersion` for this ecosystem. Don't "correct" it to + `monitoring.coreos.com/v1` even if that's what's more commonly seen elsewhere. ## appsettings.json @@ -59,10 +63,11 @@ spec: ``` Apply it in the `Kubernetes Deploy` workflow step, same `Get-Content | ExpandEnvironmentVariables -| kubectl apply` pattern as every other manifest. +| kubectl apply` pattern as every other manifest. Also add `.kubernetes\service-monitor.yaml = +.kubernetes\service-monitor.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see +AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual +Studio's Solution Explorer otherwise. ## After making the change - Show the user every file touched. -- If step 3's CRD availability is uncertain, say so explicitly rather than silently assuming the - `ServiceMonitor` will apply cleanly. diff --git a/.github/prompts/nano-add-public-exposure.prompt.md b/.github/prompts/nano-add-public-exposure.prompt.md index ed491252..d7f6884f 100644 --- a/.github/prompts/nano-add-public-exposure.prompt.md +++ b/.github/prompts/nano-add-public-exposure.prompt.md @@ -21,10 +21,24 @@ time - it specifically requires this. Ask the user up front rather than assuming via `Program.cs`. 2. **Is the app already publicly exposed?** Check for `.kubernetes/httproute-80.yaml`/ `httproute-443.yaml`. If present, say so and stop. -3. **Does the user also want Availability Check?** Ask explicitly if not already stated - see +3. **Does this app have `BaseEntityUserController` or `BaseAuthController` registered?** Search + the project for a controller deriving either (or `BaseAuthController`). Per + AGENTS.md's [Controllers § Public API vs internal service](#public-api-vs-internal-service), + both are internal-service-only features: + - `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` + **anonymously by design**, safe only on an internal network. + - `BaseAuthController` in transient mode (Identity absent, external login configured) + auto-maps an endpoint that trusts caller-supplied JWT claims verbatim (see + `nano-add-authentication-jwt`'s own warning on this). + + Finding either is a strong signal this app is meant to be called *through* a Public API's Api + Client, not reached directly - **stop and confirm with the user this is intentional** before + proceeding; don't silently expose it. If they confirm, proceed but restate the risk explicitly + in the after-change summary rather than treating the confirmation as closing the topic. +4. **Does the user also want Availability Check?** Ask explicitly if not already stated - see above. If yes, run `nano-add-availability-check` after this skill completes (it depends on the hostname/HTTPS wiring this skill adds). -4. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, +5. **Sub-domain name.** Ask what public sub-domain this app should be reachable at (e.g. `papi`, `nano`) - becomes `SUB_DOMAIN_NAME`, combined with every DNS zone configured in the target Azure resource group at deploy time (an app can end up reachable under several zones/domains at once, not just one). @@ -121,15 +135,21 @@ spec: port: 8080 ``` +Also add `.kubernetes\httproute-80.yaml = .kubernetes\httproute-80.yaml` and +`.kubernetes\httproute-443.yaml = .kubernetes\httproute-443.yaml` to `{name}.sln`'s `.kubernetes` +`SolutionItems` block (see AGENTS.md's Solution Structure note) - new files under `.kubernetes/` +don't show up in Visual Studio's Solution Explorer otherwise. + `%ROUTE_HOST_NAMES%` and `%GATEWAY_NAME%` are **not** static env vars - they're derived at deploy time (see below), one hostname line per DNS zone found in the target Azure resource group, so an app can be reachable under multiple domains without per-domain config. ## GitHub Actions -1. **Workflow env vars**: +1. **Workflow env vars** - `SUB_DOMAIN_NAME` is whatever the user answered in step 5 above; never + invent or guess a value for it: ```yaml - SUB_DOMAIN_NAME: papi + SUB_DOMAIN_NAME: AZURE_GROUP_DNS: ${{ vars.AZURE_RESOURCE_GROUP_DNS }} ``` 2. **Derive the hostnames and gateway**, in the `Kubernetes Deploy` step, before any manifest is @@ -154,7 +174,11 @@ group, so an app can be reachable under multiple domains without per-domain conf ## After making the change - Show the user every file touched, grouped by concern (local dev, Kubernetes, CI). -- If step 3 confirmed Availability Check is also wanted, hand off to +- If step 3 found `BaseEntityUserController`/`BaseAuthController` on this app and the user + confirmed exposing it anyway, restate the specific risk one more time in plain terms (anonymous + password-reset endpoints, or claim-forging transient login) rather than letting the earlier + confirmation stand as the only mention of it. +- If step 4 confirmed Availability Check is also wanted, hand off to `nano-add-availability-check` next rather than leaving it unaddressed. - Mention `AGENTS.md`'s `#### Http Policy Headers` (CORS, HSTS, CSP, security headers) as a related but separate concern worth considering for a publicly-reachable app - this skill diff --git a/.github/prompts/nano-add-startup-task.prompt.md b/.github/prompts/nano-add-startup-task.prompt.md index e3933251..a5bb19e6 100644 --- a/.github/prompts/nano-add-startup-task.prompt.md +++ b/.github/prompts/nano-add-startup-task.prompt.md @@ -15,10 +15,11 @@ task - this is for your own one-time initialization work. 1. **Name and job.** Ask if not already given - what needs to happen once before the app is considered ready (cache warm-up, an external dependency check, etc.). 2. **Must it be allowed to fail the whole app?** Per AGENTS.md: if `OnStartAsync` throws, **the - exception propagates and the application fails to start** - a startup task cannot fail - silently, unlike a Console Worker. If the user actually wants best-effort/non-fatal behavior - instead, a [Console Worker](nano-add-console-worker) (Console apps only) or a plain - `IHostedService` might be the better fit - confirm before assuming a hard failure is wanted. + exception propagates and the application fails to start** - confirm that's actually wanted for + this task before assuming it. If the user wants best-effort/non-fatal behavior instead, wrap + the task's own logic in a `try`/`catch` inside `OnStartAsync` (log and swallow, or record a + flag another part of the app can check) rather than letting it propagate - the task itself + still runs at the same point in startup either way, only the failure handling changes. 3. **Does `OnStopAsync` need to do real cleanup?** Read the timing note below before relying on it for anything tied to actual application shutdown. diff --git a/.github/prompts/nano-add-storage-provider.prompt.md b/.github/prompts/nano-add-storage-provider.prompt.md index fc23b94c..279eda77 100644 --- a/.github/prompts/nano-add-storage-provider.prompt.md +++ b/.github/prompts/nano-add-storage-provider.prompt.md @@ -20,12 +20,18 @@ provisioning step differ. ## Before making any change, determine -1. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type +1. **Is this app meant to be a Public API?** Per AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service), a Public API composes Api Clients into + responses and has no `IRepository` of its own - a Storage provider is *allowed* there (not a + hard block), but it's a deviation from that lean-façade design, not the default. If this app is + a Public API, confirm with the user that file storage genuinely belongs on this app rather than + on an internal service reached via Api Client, before proceeding. +2. **Which provider.** `Local` or `Azure` (see AGENTS.md's provider table for package/type names). Ask the user if not already given. -2. **Is a storage provider already registered?** Check `Program.cs` for an existing +3. **Is a storage provider already registered?** Check `Program.cs` for an existing `.AddNanoStorage<...>()` call - like eventing, there's one `IPathProvider` implementation per app, not a multi-provider case. If one exists, treat this as a replace and say so. -3. **Is a package reference even needed?** Same check as the other add-provider skills: look for +4. **Is a package reference even needed?** Same check as the other add-provider skills: look for `NanoCore`/`Nano.All` (directly, or transitively via a `.Models` project). If found, skip the package step. Otherwise add `` to the **application project**, matching the version of the project's existing Nano @@ -156,20 +162,30 @@ provider) - there's nothing to run, just a directory. and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the - `StatefulSet` itself is applied. + `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = + .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = + .kubernetes\service-headless.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. ## Kubernetes - Azure -This provider assumes the app already has **Managed Identity** wired (`nano-add-azure-managed-identity` -- service-account.yaml, workload-identity annotations, the CI "Managed Identity" step that -produces `$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the -fileshare mount authenticates via that identity, not a stored credential, and unlike Data's -`AuthenticationType`, Azure storage has no credentials-based fallback at all (per AGENTS.md's -`Configuration` table, `Storage` has no `AuthenticationType` setting) - Managed Identity is not -optional for this provider. If the project doesn't have it yet, point the user at -`nano-add-azure-managed-identity` first. It also assumes the target Azure Storage **account** already -exists - provisioning the account itself is out of this skill's scope, only the fileshare *on* -it is provisioned below. +This provider requires **Managed Identity** (`nano-add-azure-managed-identity` - service-account.yaml, +workload-identity annotations, the CI "Managed Identity" step that produces +`$env:IDENTITY_NAME`/`$env:IDENTITY_CLIENT_ID`/`$env:IDENTITY_PRINCIPAL_ID`) - the fileshare mount +authenticates via that identity, not a stored credential, and unlike Data's `AuthenticationType`, +Azure storage has no credentials-based fallback at all (per AGENTS.md's `Configuration` table, +`Storage` has no `AuthenticationType` setting). This isn't an adjacent, optional feature to ask +about before pulling in - it's a hard technical dependency of Azure storage itself: the +"Storage Role Permissions" step below directly references `$env:IDENTITY_PRINCIPAL_ID`, which is +simply undefined without it, so the generated workflow would fail the first time it runs. If the +project doesn't have Managed Identity yet, **apply `nano-add-azure-managed-identity` as part of this +same change** rather than stopping to ask or leaving it as a dangling prerequisite - then note in +the final summary that it was added alongside storage, so the user isn't surprised by the extra +files. It also assumes the target Azure Storage **account** already exists - provisioning the +account itself is out of this skill's scope (a real external resource to ask about, unlike +Managed Identity, which is just configuration this skill can apply itself), only the fileshare +*on* it is provisioned below. 1. **Workflow env vars**: ```yaml @@ -273,7 +289,11 @@ it is provisioned below. ``` placed before the `storage-pv.yaml`/`storage-pvc.yaml` apply block (which come before `deployment.yaml`, same order as every other manifest). The suffix keeps the PV/PVC name - unique per identity, avoiding collisions across redeploys. + unique per identity, avoiding collisions across redeploys. Also add + `.kubernetes\storage-pv.yaml = .kubernetes\storage-pv.yaml` and `.kubernetes\storage-pvc.yaml + = .kubernetes\storage-pvc.yaml` to `{name}.sln`'s `.kubernetes` `SolutionItems` block (see + AGENTS.md's Solution Structure note) - new files under `.kubernetes/` don't show up in Visual + Studio's Solution Explorer otherwise. 6. **`.kubernetes/deployment.yaml`** - mount it (`ReadWriteMany`, so multiple replicas can share it, unlike `Local`), plus the same `tmp` `emptyDir` volume noted in the Local section above: ```yaml @@ -297,6 +317,8 @@ it is provisioned below. - Show the user every file touched, grouped by concern (app code, local docker-compose, Kubernetes, and for Azure, CI) - too many files for a flat list to be easy to sanity-check. - If the package step was skipped (`NanoCore`/`Nano.All` already covering it), say so explicitly. -- For `Azure`, if Managed Identity (point the user at `nano-add-azure-managed-identity`) or the storage - account weren't already in place, say so explicitly rather than silently doing only the - app-code half of the job. +- For `Azure`, if Managed Identity wasn't already wired, say explicitly that it was added as part + of this change (list its files alongside storage's own) - don't let it pass as an unremarked + side effect. If the target Storage **account** doesn't exist yet, that's still an external + prerequisite outside this skill's scope - flag it rather than silently doing only the app-code + half of the job. diff --git a/.github/prompts/nano-remove-api-client.prompt.md b/.github/prompts/nano-remove-api-client.prompt.md index 214d85b4..b9338901 100644 --- a/.github/prompts/nano-remove-api-client.prompt.md +++ b/.github/prompts/nano-remove-api-client.prompt.md @@ -1,49 +1,80 @@ --- mode: agent -description: Remove a Nano Api Client from an application - deletes the BaseApiClient subclass and its App:Apis configuration entry. Use when the user asks to remove a call to another Nano service/API, remove an API client, or stop consuming an internal service from a Nano API, Web, or Console application. +description: Delete an Api Client's definition entirely - the BaseApiClient/BaseIdentityApiClient subclass - from the owning service's {Name}.Models project. Use when the user asks to stop exposing a client to other services entirely, or delete a client's definition from a Nano API, Web, or Console application. For removing one custom method (and its paired controller action), see nano-remove-custom-endpoint's internal-service path instead. --- # Nano remove API client -Removes a Nano Api Client from an existing application - the counterpart to -`nano-add-api-client`. Read that skill first - this one undoes exactly what it adds. +Deletes an Api Client's definition - the `BaseApiClient`/`BaseIdentityApiClient` subclass - from +the *owning* service's `{Name}.Models` project, entirely. The counterpart to +`nano-add-api-client`. This is a different, more consequential operation than a single +consumer dropping the client: every application currently consuming this class loses it. -## Before making any change, determine +**This skill never touches the controller actions those custom methods called.** Deleting the +client-side definition doesn't imply deleting the server-side logic behind it - those actions +keep working, just unreachable through this particular typed client. If the user also wants a +given action removed, that's a separate, explicit decision - point them at +`nano-remove-custom-endpoint` for each one, on the owning service's app project. -1. **Which client, and where is it defined?** Confirm the class name and whether it lives in this - app's own project or a referenced `{TargetName}.Models` project (owning-service convention - - removing it from a shared `.Models` project affects every consumer of that project, not just - this app; confirm that's actually intended before deleting a shared file). -2. **What depends on it?** Search for the client class used as a constructor parameter (controller - or worker). Unlike most Nano dependencies, this isn't a startup-crash risk - the client itself - has no required-service semantics beyond normal C# compilation - but removing the class while - something still references it simply **won't compile**. Find every consumer first; either this - skill also removes those usages (ask the user), or stop and let them decide what replaces the - call. -3. **Is the `App:Apis` entry safe to remove alone?** If the class itself is defined in a shared - `.Models` project used by other apps too, and only *this* app's config/injection should go - away, don't delete the class - just remove this app's `App:Apis` entry and its injection site. +If the actual goal is just "this app should stop calling that service," not "delete the client +definition entirely," that's `nano-remove-api-client-configuration`'s job instead (the *consumer* +side) - point the user there; don't delete a shared definition to satisfy one consumer's request. -## appsettings.json +If the actual goal is "remove this one custom method," not the whole class, that's +`nano-remove-custom-endpoint`'s internal-service path instead - a custom method and the +controller action it calls are one paired contract, removed together; this skill only handles +deleting the class itself. -Remove the `App:Apis:{ClientClassName}` section from the base `appsettings.json`, and its -`LogInRoot`/other overrides from `appsettings.Development.json` and any Staging/Production -secret wiring, if present. +## Before making any change, determine -## Client class and custom requests +1. **Is this really a full class removal?** If the request is actually about one custom method, + redirect to `nano-remove-custom-endpoint`'s internal-service path rather than doing partial + work here. +2. **Who else consumes this class - and say plainly that this check is necessarily incomplete.** + Search every *locally visible* application (this repo, or other repos actually checked out and + reachable) for an `App:Apis` entry matching this class name, or the class injected into a + controller/worker. But if `{Name}.Models` is published (NuGet or private feed), consumers can + exist in repos this session has no access to at all - finding zero locally is not the same as + confirming zero exist. Present it that way to the user: "no *locally visible* consumers found," + not "nobody else uses this." List whatever was found, name the blind spot explicitly, and + confirm with the user before proceeding - this is not a decision to make unilaterally, and it + can't be made with full information either. +3. **What is actually being lost - list every custom method by name, not just "the class."** A + bare pass-through client with nothing but `.Entity`/`.Auth`/`.Audit` usage is a low-stakes + delete; a client with several built-out custom methods represents real, deliberate contract + work. Enumerate them (name, and what each does per its doc comment) as part of what the user is + confirming in step 2 - don't let a multi-method client get deleted on the same casual footing + as an empty stub just because both are technically "one class." +4. **Route constants are conditional on whether the controller action is also going - never + remove one on its own.** A route constant in `{Name}.Models/Consts/` is normally referenced + from *both* this client's request and the controller action it calls (per + `nano-add-custom-endpoint`'s route-constant-sharing convention). Since this skill leaves + controller actions untouched by default, deleting the constant while the action still + references it is a **guaranteed compile break in the owning service's own app project** - + not a cross-repo risk, a self-inflicted one. Only remove a route constant here if the user has + also confirmed the corresponding controller action is being removed in the same change (via + `nano-remove-custom-endpoint`); otherwise leave it in place even though it looks unused + from the client's side. -If nothing else consumes the class (confirmed in step 2) and it isn't shared with other apps: -delete `{TargetName}.Models/Api/{ClientName}.cs` and any request/response types under -`Api/Requests/`/`Api/Responses/` that exist solely to support this client. +## Client class -## Injection sites +Delete `{ThisApp}.Models/Api/{ClientName}.cs` in full, along with every request/response type +identified in step 3 that existed solely to support its custom methods (check nothing else +references them first - a shared DTO used elsewhere should stay). -Remove the constructor parameter and field from every controller/worker that took this client, -per step 2 - this is a compile-breaking change if left in place after the class is gone. +Leave every route constant in place unless step 4's condition was actually met for it. Leave +every controller action untouched, always - see the note above. ## After making the change -- Show the user every file touched/deleted. -- If step 1 or 2 stopped the skill early (shared `.Models` project, or unresolved consumers), - that's the whole response - don't delete a shared file or leave broken constructor parameters - behind. +- Show the user every file touched/deleted in this app's `.Models` project, and restate plainly + that the controller actions those custom methods called were left untouched. +- Restate step 2's blind spot one more time - this only confirms no locally visible consumer + remains, not that none exist anywhere. +- Restate every consuming application identified in step 2 as still needing its own cleanup - + this skill only removes the definition; each consumer's own `App:Apis` entry and injection site + is `nano-remove-api-client-configuration`'s job, on that consumer's side, once they've been + told. +- If step 2 surfaced consumers the user hadn't accounted for, that may be reason enough to stop + here and let them decide how to proceed with each one, rather than deleting out from under + them. diff --git a/.github/prompts/nano-remove-authentication-apikey.prompt.md b/.github/prompts/nano-remove-authentication-apikey.prompt.md index 372cf050..7ec1d5fb 100644 --- a/.github/prompts/nano-remove-authentication-apikey.prompt.md +++ b/.github/prompts/nano-remove-authentication-apikey.prompt.md @@ -44,9 +44,26 @@ there's no issuer/validator distinction to worry about here - always safe to rem - Remove the `Data__Identity__ApiKey__Secret` entry from `.kubernetes/deployment.yaml`'s container `env`. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-authentication-jwt`'s equivalent note: +- The `auth-api-key-secret` Kubernetes `Secret` already sitting in the cluster from prior + deploys. +- The **GitHub repository secrets** themselves (`PRODUCTION_AUTH_API_KEY_SECRET`/ + `STAGING_AUTH_API_KEY_SECRET`) - removing the workflow's `AUTH_API_KEY_SECRET` env var line + just stops this workflow from *reading* them; they stay stored in the repo/organization's + GitHub settings until someone deletes them there directly (`gh secret delete` or the Settings + UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow line" means +"removed the actual secret." + ## After making the change - Show the user every file touched/deleted. - Restate step 2's outcome now that it's done - either "JWT auth still works, the key-exchange endpoint is gone" or "this app now has no authentication at all, every endpoint is anonymous" - whichever applies. Worth a second, explicit confirmation, not just a line in a file list. +- Restate the ⚠ above - the live `auth-api-key-secret` Kubernetes secret and the + `PRODUCTION_AUTH_API_KEY_SECRET`/`STAGING_AUTH_API_KEY_SECRET` GitHub secrets still exist; only + this app's manifest/workflow references to them were removed. diff --git a/.github/prompts/nano-remove-authentication-jwt.prompt.md b/.github/prompts/nano-remove-authentication-jwt.prompt.md index 0b28bb69..273b0be6 100644 --- a/.github/prompts/nano-remove-authentication-jwt.prompt.md +++ b/.github/prompts/nano-remove-authentication-jwt.prompt.md @@ -37,6 +37,35 @@ even if API-key auth stays configured afterward - see step 3. 4. **Custom controller logic?** Open `AuthController.cs` before deleting it - if it's still just the one-line subclass `nano-add-authentication-jwt` generates, delete freely. If someone added custom actions to it since, stop and confirm with the user before removing their code. +5. **Was `RootLogin` wired up for Staging/Production, not just Development?** `nano-add-authentication-jwt` + only ever adds it as a Development-only convenience by default, but nothing stops someone from + wiring the full Staging/Production version by hand (per AGENTS.md: an `auth-root-login-secret` + Kubernetes secret, `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD` GitHub secrets, and + `App__Authentication__Jwt__RootLogin__Username`/`Password` in `deployment.yaml`/`cronjob.yaml`). + Check for `.kubernetes/auth-root-login-secret.yaml` and those deployment env entries - don't + assume they're absent just because the add-skill doesn't create them by default. +6. **Any custom external-login repository?** Search for classes deriving + `BaseAuthExternalRepository` (see `nano-add-authentication-jwt`'s "External Login" + section). These don't fail to compile once `Jwt` is gone - they're plain classes, and + `AuthExternalRepositoryAggregator`'s registration isn't itself conditional on `Jwt` - but the + `/auth/login/external/{providerName}/...` route they backed stops existing the moment + `AuthController` is deleted (step 4's note), since `IAuthRepository`/`AuthController` + registration is what's actually gated on `Jwt != null`. They become silent dead code, not a + crash. Don't delete them unasked - they may hold real integration logic worth keeping if `Jwt` + comes back later - but flag every one found, the same treatment `nano-remove-identity`/ + `nano-remove-eventing-provider` give their own orphaned-code cases. Check its constructor for + an injected options class too - per the add-skill, a custom provider typically has its own + config section (API key, base URL, etc.) bound to one; that section becomes equally orphaned + and is easy to miss since it isn't part of `App:Authentication:Jwt` at all. +7. **Was `Jwt.ExternalLogins` (built-in Facebook/Google/Microsoft) ever configured, and if so, how + were its `AppSecret`/`ClientSecret` stored for Staging/Production?** Per + `nano-add-authentication-jwt`, there's no standard Kubernetes/GitHub-secret convention for + these - the add-skill explicitly asks the user how they want them stored rather than assuming + one, so whatever exists here is bespoke to this app, not something you can find by checking a + fixed file/secret name the way `auth-jwt-secret` or `auth-root-login-secret` can be. Search + `.kubernetes/deployment.yaml` and the workflow for anything referencing + `App__Authentication__Jwt__ExternalLogins__*` and ask the user to confirm what it is and + whether it should be removed too - don't assume config-file removal alone caught it. ## appsettings.json @@ -54,7 +83,10 @@ Delete `Controllers/AuthController.cs` - see the note at the top; this isn't con If step 2 found this app is the issuer: -- Delete `.kubernetes/auth-jwt-secret.yaml`. +- Delete `.kubernetes/auth-jwt-secret.yaml`, and remove its + `.kubernetes\auth-jwt-secret.yaml = .kubernetes\auth-jwt-secret.yaml` line from `{name}.sln`'s + `.kubernetes` `SolutionItems` block - `nano-add-authentication-jwt` added it there, and a + deleted file left in the `.sln` shows up as missing in Visual Studio's Solution Explorer. - Remove its apply step from the `Kubernetes Deploy` workflow step. - Remove the `AUTH_JWT_PUBLIC_KEY`/`AUTH_JWT_PRIVATE_KEY` workflow env vars. - If this app was the **only** issuer in the solution, every validator-only app that references @@ -62,12 +94,39 @@ If step 2 found this app is the issuer: explicitly; it's outside this skill's scope (a different app's files), but silently leaving it broken elsewhere is worse than mentioning it. +⚠ This does **not** delete either underlying live resource - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-azure-managed-identity` (doesn't delete the Azure identity) and +`nano-remove-availability-check` (doesn't delete the Application Insights resource): +- The `auth-jwt-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. If + any validator-only app still references it, the secret staying live is actually what keeps them + working - don't suggest deleting it (`kubectl delete secret auth-jwt-secret`) unless the user + confirms every app that reads it is also being removed or reworked. +- The **GitHub repository secrets** themselves (`{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY`/`_PRIVATE_KEY` + for both Staging and Production) - removing the workflow's `AUTH_JWT_PUBLIC_KEY`/ + `AUTH_JWT_PRIVATE_KEY` env var lines just stops this workflow from *reading* them; the secrets + stay stored in the repo/organization's GitHub settings until someone deletes them there + directly (`gh secret delete` or the Settings UI) - this skill has no way to do that itself. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual secret." + ## Kubernetes - every app (issuer and validator) Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__PrivateKey` entries (whichever are present - a validator only ever has `PublicKey`) from `.kubernetes/deployment.yaml`'s container `env`. +## Kubernetes / GitHub Actions - RootLogin, only if step 5 found it wired up + +If `.kubernetes/auth-root-login-secret.yaml` or the `RootLogin` deployment env entries exist: + +- Delete `.kubernetes/auth-root-login-secret.yaml`, and remove its matching line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block if it was added there. +- Remove its apply step from the `Kubernetes Deploy` workflow step. +- Remove the `{environment}_AUTH_ROOT_LOGIN_USERNAME`/`_PASSWORD`-sourced workflow env vars. +- Remove the `App__Authentication__Jwt__RootLogin__Username`/`Password` entries from + `.kubernetes/deployment.yaml`/`cronjob.yaml`'s container `env`. + ## After making the change - Show the user every file touched/deleted. @@ -76,4 +135,14 @@ Remove the `App__Authentication__Jwt__PublicKey`/`App__Authentication__Jwt__Priv JWT exchange endpoint is gone" - whichever applies. This is the one thing most worth a second, explicit confirmation rather than folding into a file list. - If step 2 found this app was the sole issuer, restate the warning about now-broken - validator-only apps elsewhere in the solution. + validator-only apps elsewhere in the solution, and the ⚠ that the live `auth-jwt-secret` + Kubernetes secret still exists in the cluster - only its manifest/CI maintenance was removed. +- If step 5 found a Staging/Production `RootLogin` wired up, confirm it was fully removed + (secret, CI, deployment env entries) - this was outside the add-skill's default behavior, so + don't assume the user already knows all three pieces existed. +- If step 6 found any custom external-login repository classes, list them explicitly as now-dead + code (no route left to invoke them) rather than leaving that for the user to discover later - + including any options class/config section feeding it, not just the repository class itself. +- If step 7 found built-in `ExternalLogins` credentials stored somewhere, restate what was found + and confirm with the user whether it was actually removed - there's no fixed convention to + verify against here, so don't imply this was handled as thoroughly as the other secrets above. diff --git a/.github/prompts/nano-remove-azure-managed-identity.prompt.md b/.github/prompts/nano-remove-azure-managed-identity.prompt.md index 5ad1e489..163b27bf 100644 --- a/.github/prompts/nano-remove-azure-managed-identity.prompt.md +++ b/.github/prompts/nano-remove-azure-managed-identity.prompt.md @@ -15,14 +15,27 @@ skill first - this one undoes exactly what it adds. `Managed Identity` workflow step. If neither exists, say so and stop. 2. **What depends on it?** Two genuinely different situations - check both, and don't treat them the same: - - **A Data provider set to `AuthenticationType: Azure`** (MySql/PostgreSQL/SqlServer). This - one has a real fallback: `Credentials`. But reverting to it isn't a config flip alone - it - needs an actual connection string/credential the user supplies (this skill can't invent - one), plus removing the CI ` Database Migration`/`SQL Server Create Database` - steps' Managed-Identity-based user creation and the `Data__AuthenticationType` - ConfigMap entry. **Ask the user which they want**: supply real credentials and revert this - provider to `Credentials` as part of this change, or leave Managed Identity in place and - stop here. Don't silently pick one. + - **A Data provider currently authenticating via Managed Identity** (MySql/PostgreSQL/ + SqlServer). **Don't check only the base `appsettings.json`** - per `nano-add-data-provider`, + `Data:AuthenticationType` is always `"Credentials"` there, even when Staging/Production + actually authenticates via Managed Identity, so the base file alone can't tell you which + world you're in. Check two places instead: + - `.kubernetes/configmap.yaml` for a `Data__AuthenticationType: %SQL_AUTH_TYPE%` entry - the + convention-following case, only present if `nano-add-data-provider`'s Staging/Production + section was wired up for this provider, and it only ever expands to `Azure`. + - `appsettings.Staging.json`/`appsettings.Production.json` directly for their own + `Data:AuthenticationType` - nothing stops someone from setting it there by hand instead of + going through the ConfigMap, so don't assume the convention was followed just because the + ConfigMap entry is absent; check both before concluding either way. + Either one set to `Azure` → this provider depends on Managed Identity. Neither → it's already + pure `Credentials` in every environment, nothing to revert, this dependency doesn't apply. + If it does apply: this one has a real fallback, `Credentials` - but reverting to it isn't a + config flip alone, it needs an actual connection string/credential the user supplies (this + skill can't invent one), plus removing the CI ` Database Migration`/`SQL Server + Create Database` steps' Managed-Identity-based user creation and the + `Data__AuthenticationType` ConfigMap entry. **Ask the user which they want**: supply real + credentials and revert this provider to `Credentials` as part of this change, or leave + Managed Identity in place and stop here. Don't silently pick one. - **Azure Storage** (`AzureFileshareProvider`, `nano-add-storage-provider`'s Azure section). Unlike Data, there's **no credentials-based fallback for Storage** - per AGENTS.md's `Configuration` table, `Storage` has no `AuthenticationType` setting at all; Azure storage's diff --git a/.github/prompts/nano-remove-data-provider.prompt.md b/.github/prompts/nano-remove-data-provider.prompt.md index 7a75f995..777c6991 100644 --- a/.github/prompts/nano-remove-data-provider.prompt.md +++ b/.github/prompts/nano-remove-data-provider.prompt.md @@ -22,12 +22,26 @@ than re-deriving them. parameter fails DI resolution the instant the provider is gone: - **`IRepository` or the `DbContext` injected directly.** Search the project for both, anywhere - controllers, services, workers. A scaffolded controller - (`nano-scaffold-entity`'s own template) always takes `IRepository` as a required parameter, + (`nano-add-entity`'s own template) always takes `IRepository` as a required parameter, so any existing entity's controller is a guaranteed hit - the app won't start at all with it left in place and the provider gone. - - **Entities.** Beyond the controller-crash risk above, `BaseEntity`-derived classes and their - mappings/query criteria become dead weight with nothing to persist them - not a crash by - themselves, but still worth surfacing. + - **Data Mappings - a build break, not just dead code, if the package is actually being + removed.** Every `Data/Mappings/Mapping.cs` file (`BaseEntityMapping`/ + `BaseMapping`) references `EntityTypeBuilder` + (`Microsoft.EntityFrameworkCore.Metadata.Builders`) - a type that only reaches this project + transitively through `Nano.Data.`, not through `Nano.App`/`Nano.Data.Abstractions`. + If step 3 below actually removes that package (i.e. the project isn't on `NanoCore`/ + `Nano.All`), every mapping file fails to compile the instant it's gone - deleting them is + required to keep the build green, not optional cleanup, but **list every mapping file this + would delete and get explicit confirmation before deleting any of them** - same rule as + deleting any other file the user didn't directly ask you to remove; don't fold it silently + into "removing the provider." If `NanoCore`/`Nano.All` stays in place instead, they still + compile fine, just with nothing left to ever apply them (`OnModelCreating`'s auto-discovery + has no `DbContext` to run from) - dead code, not a break, and there's no deletion to confirm. + - **Entities and query criteria.** Beyond the mapping risk above, `BaseEntity`-derived classes + and their query criteria become dead weight with nothing to persist them - not a crash or + build break by themselves (they don't reference EF Core types directly), but still worth + surfacing. - **Identity/Master auth.** If `Data:Identity` is configured (see AGENTS.md's `#### Identity`) or a JWT auth setup depends on the Identity store this context provides, removing the provider breaks authentication entirely. @@ -51,6 +65,14 @@ blank-app placeholder and the `_` discard parameter. - `Data/DbContextFactory.cs` (if present - `InMemory` never had one) - `Migrations/` folder (if present - dead without the factory that constructs the context for `dotnet ef`; keeping stale migration files around with no way to run them is just clutter) +- **`Data/Mappings/*.cs` (every entity's mapping) - required, not optional, whenever step 3 is + actually removing the `Nano.Data.` package, but only after step 2's confirmation.** + Per step 2's Data Mappings risk, leaving these behind breaks the build, not just clutters it - + so deletion is the correct end state once confirmed, but list the files and get that + confirmation first rather than deleting them as an unannounced side effect of removing the + provider. If `NanoCore`/`Nano.All` covers the project instead (package step skipped), they're + safe to leave - flag them as dead code per step + 2 rather than deleting, since the entities/query criteria they map are also staying. ## appsettings.json @@ -63,33 +85,42 @@ override, per `nano-add-data-provider`'s SqLite section) - remove it from there ## docker-compose.yml -Comment out the `database` service block (don't delete it) - matching the established -convention of keeping all providers' blocks available for future reference, just inactive. Also -remove `depends_on: [database]` from the app's own service entry, since nothing is left to -depend on. Skip this step entirely for `InMemory` and `SqLite` - neither ever had a `database` -service. +Delete the `database` service block entirely (don't comment it out - `nano-add-data-provider` +adds only the one block for the chosen provider, with no dormant alternatives for the others, so +there's nothing to preserve here either). Also remove `depends_on: [database]` from the app's +own service entry, since nothing is left to depend on. Skip this step entirely for `InMemory` and +`SqLite` - neither ever had a `database` service. ## SqLite-specific cleanup If the provider was `SqLite`, additionally: -- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/data-pvc.yaml`. -- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` block from the +- Delete `.kubernetes/data-storageclass.yaml` and `.kubernetes/service-headless.yaml` (there's no + separate PVC file to delete - `nano-add-data-provider` never creates one; the `StatefulSet`'s + `volumeClaimTemplates` provisions one per pod automatically, and is removed as part of reverting + `stateful-set.yaml` back to a plain `Deployment` below). +- Remove their `Get-Content | ExpandEnvironmentVariables | kubectl apply` blocks from the `Kubernetes Deploy` workflow step. -- Remove the `volumeMounts`/`volumes` entries referencing `%SERVICE_NAME%-volume` from - `.kubernetes/deployment.yaml`. +- Revert `.kubernetes/stateful-set.yaml` back to a plain `deployment.yaml` (`kind: Deployment`, + drop `serviceName` and `volumeClaimTemplates`) unless another SqLite-needing reason for a + `StatefulSet` remains - and change `.kubernetes/autoscaler.yaml`'s `scaleTargetRef.kind` back + from `StatefulSet` to `Deployment` alongside it. +- Remove the `volumeMounts` entry referencing `%SERVICE_NAME%-volume` from the container spec. - Remove the `SQL_SIZE` workflow env var, if nothing else uses it. ## Staging/Production cleanup (MySql, PostgreSQL, SqlServer only) Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). -1. **Workflow steps** - remove ` Database Migration`. For `SqlServer` specifically, - also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` always adds - together for that provider). -2. **Workflow env vars** - remove `SQL_TYPE`, `SQL_AUTH_TYPE`, `SQL_NAME`. Only remove +1. **Workflow steps** - remove ` Database Migration` (this is the only migration step + present - `nano-add-data-provider` no longer adds the other two providers' steps as dormant + `if:`-guarded alternatives, so there's nothing else to find here). For `SqlServer` + specifically, also remove `SQL Server Create Database` (the two steps `nano-add-data-provider` + always adds together for that provider). +2. **Workflow env vars** - remove `SQL_AUTH_TYPE`, `SQL_NAME` (there is no `SQL_TYPE` to remove - + `nano-add-data-provider` no longer adds one). Only remove `AZURE_GROUP_DATABASE`/`AZURE_GROUP_LOGS`/`DOTNET_EF_TOOLS_VERSION` if nothing else in the - workflow still references them (`AZURE_GROUP_LOGS` in particular is also used by an - Availability Check step, if one exists - check before removing). + workflow still references them (`AZURE_GROUP_LOGS` is only added for `SqlServer` in the first + place, and is also used by an Availability Check step, if one exists - check before removing). 3. **Kubernetes secret** - delete `.kubernetes/auth-sql-secret.yaml` and remove its apply block from the `Kubernetes Deploy` step. 4. **ConfigMap** - remove `Data__AuthenticationType: %SQL_AUTH_TYPE%` from @@ -103,7 +134,9 @@ Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). Staging/Production CI + K8s) - same reasoning as the add skill: too many files for a flat list to be easy to sanity-check. - Restate anything flagged in step 2 - required `IRepository`/`DbContext` injections that will - now crash the app, plus orphaned entities or broken Identity/auth - one more time here, even - if the user already confirmed it; worth a second visible reminder once the removal is done. + now crash the app, whether mapping files were deleted (build break avoided) or left as dead + code (`NanoCore`/`Nano.All` case), plus orphaned entities/query criteria or broken + Identity/auth - one more time here, even if the user already confirmed it; worth a second + visible reminder once the removal is done. - If a step was skipped because the project uses `NanoCore`/`Nano.All`, or because a Staging/ Production section never existed to begin with, say so explicitly. diff --git a/.github/prompts/nano-remove-health-checks.prompt.md b/.github/prompts/nano-remove-health-checks.prompt.md index b1cfab6f..0c68d6a5 100644 --- a/.github/prompts/nano-remove-health-checks.prompt.md +++ b/.github/prompts/nano-remove-health-checks.prompt.md @@ -23,8 +23,6 @@ the same "never do one half without the other" rule applies in reverse here. `App:HealthCheck` is gone (same dependency as at add-time, just now unsatisfied). Not a crash, but tell the user - leaving those blocks in place with no effect is confusing without an explanation. - - **`Metrics`, if enabled, is unaffected** - it has no dependency on `HealthCheck` in either - direction; don't touch it. ## Kubernetes diff --git a/.github/prompts/nano-remove-identity.prompt.md b/.github/prompts/nano-remove-identity.prompt.md index 3763390e..228f3ae5 100644 --- a/.github/prompts/nano-remove-identity.prompt.md +++ b/.github/prompts/nano-remove-identity.prompt.md @@ -36,7 +36,30 @@ exactly what it adds. If either applies, tell the user exactly what removing Identity will do (crash vs. silent endpoint loss) and confirm before proceeding - don't remove out from under them without saying so. -3. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate +3. **Does this app's own Api Client derive from `BaseIdentityApiClient`?** + Check `{ThisApp}.Models/Api/` for a client using the identity-backed base class with this + app's `User` entity as `TUser`. If so, it must be reverted to the plain + `BaseApiClient`/`BaseApiClient` base as part of this same change, not left for + later - either as a **guaranteed compile break** (if step 5 below deletes the entity, `TUser` + stops existing) or as a client that compiles but lies about what the target app actually + serves (if step 5 converts the entity back to a plain one instead - the `.Identity` method + group it still exposes has nothing left to call either way). +4. **Does the entity carry anything beyond what `nano-add-identity` itself would have + generated?** This determines whether step 5 below deletes the entity outright or converts it + back to a plain one - check before touching any file: + - Custom scalar properties on the entity beyond what `BaseEntityUser` provides. + - Custom controller actions beyond the standard CRUD + identity-management set + `nano-add-identity` added. + - Any other code in the project that depends on this entity for a reason that has nothing to + do with Identity (e.g. it's referenced by other entities' navigations, or it's genuinely this + app's core business entity and Identity was layered onto it after the fact, per + `nano-add-identity`'s own "convert an existing plain entity" case). + If none of these apply, the entity is pure boilerplate from `nano-add-identity` with nothing + else depending on it - full deletion (step 5's first option) is safe. If any apply, **don't + default to deleting it** - ask the user whether they want full deletion anyway (only correct + if the entity truly has no remaining purpose once Identity is gone) or an in-place conversion + back to a plain entity that keeps everything custom intact. +5. **No package reference to remove.** Matches `nano-add-identity`: Identity was never a separate NuGet package, so there's nothing to remove from the `.csproj` here either. ## appsettings.json @@ -47,24 +70,53 @@ section lives in the base file only. ## User entity, mapping, and controller -Delete the full file set `nano-add-identity` created for whichever entity derives -`BaseEntityUser`/`BaseEntityUser` (conventionally, but not always, named `User` - find -it by searching for that base class if the name isn't obvious): +Find the entity by searching for whichever one derives `BaseEntityUser`/`BaseEntityUser` +(conventionally, but not always, named `User`). What happens to it depends on step 4's answer: +**Pure boilerplate (no custom content, or the user chose full deletion anyway):** delete the +whole file set: - `Data/.cs` (or the `.Models` project in a split layout). - `Data/Mappings/Mapping.cs`. - `Criterias/QueryCriteria.cs` (API/Web only - never existed for Console). - `Controllers/sController.cs` (API/Web only). -Don't leave the mapping behind even temporarily - `BaseEntityUserMapping` configures a -required relationship to the underlying `IdentityUser` row (AGENTS.md's Data Mappings table), -which stops being mapped the instant `Data:Identity` is gone; leaving the entity/mapping in place -without the config breaks EF model building at startup, not just at the controller level covered -in step 2. +**Has custom content the user wants kept:** convert in place instead of deleting - the mirror +image of `nano-add-identity`'s "convert an existing plain entity" case: +- **Data model**: change the base class from `BaseEntityUser`/`BaseEntityUser` back to + `BaseEntity`/`BaseEntity` - nothing else about the class changes; every custom + property stays. +- **Mapping**: change the base class from `BaseEntityUserMapping`/`` + back to `BaseEntityMapping`/`` - this is what actually matters here, + not optional cleanup: `BaseEntityUserMapping` configures a required relationship to the + underlying `IdentityUser` row (AGENTS.md's Data Mappings table), which stops being mapped the + instant `Data:Identity` is gone. Leaving the old base class in place breaks EF model building at + startup, not just the controller-level crash covered in step 2 - converting the mapping is not + something to skip even when keeping the entity. +- **Query criteria**: untouched - nothing identity-specific lives here either way. +- **Controller**: change the base class from `BaseEntityUserController<...>` back to + `BaseEntityController<...>` (or whichever narrower capability base fits), drop the + `IIdentityRepository` constructor parameter, and remove only the identity-management actions + `nano-add-identity` added - keep every other custom action as-is. + +Either way, don't leave a `BaseEntityUserMapping`/`BaseEntityUserController` behind pointed at a +`Data:Identity` section that no longer exists - that's the one part of this that's never safe to +defer, in either branch. + +## Api Client side + +If step 3 found a client on `BaseIdentityApiClient`, **revert it to +`BaseApiClient`/`BaseApiClient`** now, as part of this same change, regardless of +which branch the section above took: the `.Identity` method group it exposed has nothing left to +call either way - the identity-management actions are gone from the controller whether the +entity itself was deleted or just converted back to a plain one. If the entity was deleted +outright, this is also a guaranteed compile break (`TUser` stops existing), not just a dangling +capability. Don't leave this as a follow-up for the user to remember separately. ## After making the change -- Show the user every file touched/deleted. +- Show the user every file touched/deleted/converted, including any Api Client reverted to its + plain base class, and say explicitly which branch step 4 took (full deletion vs. converted back + to a plain entity) and why. - Restate anything flagged in step 2 - the guaranteed controller crash, plus the specific Authentication endpoints that silently disappear if Jwt/API-key auth was configured - one more time here, even if the user already confirmed it. diff --git a/AGENTS.md b/AGENTS.md index bad804b2..1210c866 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1617,6 +1617,18 @@ are all nullable — each is populated only if the matching config exists, and t | `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | +⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` +binds `TransientClaims`/`TransientRoles` straight from the request body and mints them into the JWT with no +server-side filtering — any anonymous caller can assert `{"transientClaims": {"IsAdmin": "true"}}` and receive +back a validly-signed token carrying it. Per the table above, this endpoint is only auto-mapped when a +`BaseAuthController`-derived class exists **and** [Identity](#identity) is **not** configured +(`ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by type scan +across the whole app, not by which controller you meant to use it for). A transient-auth app that needs its own +server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a +generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own +base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing +claims/roles only from trusted server-side data, never from caller input. + ##### Custom external provider Derive from `BaseAuthExternalRepository`, implement the two abstract methods, and give it a provider @@ -1777,7 +1789,11 @@ explicit about, since it changes what an action's body actually does: - **Public API controller** — the customer/end-user-facing application (e.g. `Api.Platform`/`Api.Admin` in this solution). Its actions compose one or more injected [Api Clients](#api-clients) into a response; it has no - `IRepository` of its own. Called "Public API," not "gateway," specifically to avoid colliding with the + `IRepository` of its own. This is the intended shape, not an absolute rule enforced anywhere — a Data, + Storage, or Eventing provider *can* be added directly to a Public API if genuinely needed (`nano-add-data-provider`/ + `nano-add-storage-provider`/`nano-add-eventing-provider` all allow it), but doing so pulls the app away from + being a thin façade and should be a deliberate exception, confirmed with whoever's asking, not the default + when scaffolding one of these. Called "Public API," not "gateway," specifically to avoid colliding with the unrelated Kubernetes Gateway API resource (`nano-add-public-exposure`'s `HTTPRoute`/`Gateway`) — "gateway" in this codebase otherwise means that Kubernetes resource, or the network-edge/cert-manager TLS-terminating layer in front of a cluster, never this application-level role. @@ -1785,6 +1801,26 @@ explicit about, since it changes what an action's body actually does: either as a custom method on an entity controller or a bare `BaseController` action. Only ever called by a Public API (or another internal service) via its Api Client — never exposed directly to untrusted clients. +⚠ **`BaseEntityUserController` and `BaseAuthController` (persistent auth) are internal-service-only features — +never add them to an app playing the Public API role, even though nothing technically stops it.** Unlike a +plain Data/Storage/Eventing provider (above, allowed as a deliberate exception), this combination is never an +acceptable exception — a Public API that adds Identity for its own login/signup needs should compose through +the owning internal service's Api Client instead (see [Api Clients § Built-in method groups](#built-in-method-groups)) +or use transient auth with server-computed claims, not host either controller itself. Two concrete reasons this +is actively dangerous, not just architecturally unusual: +- `BaseEntityUserController` exposes `password/reset/token`/`{id}/password/reset` **anonymously by design**, for + internal-network use only — see [Identity user controller](#identity-user-controller)'s own ⚠ Security note. +- `BaseAuthController` in transient mode (no Identity, external login configured) auto-maps an endpoint that + trusts caller-supplied JWT claims verbatim — see [Authentication](#authentication)'s own ⚠ note on + `AuthTransientRepository`. + +A Public API that needs to offer login/signup/password-management to end users does so by **composing calls to +the internal service that actually owns Identity**, through that service's Api Client (its `.Identity`/`.Auth` +method groups — see [Api Clients § Built-in method groups](#built-in-method-groups)), or by implementing its +own transient auth with server-computed claims (see `Api.Admin`'s `AccountsController` in this codebase for a +working example) — never by hosting `BaseEntityUserController`/persistent `BaseAuthController` on the +Public API itself. + #### Entity controller hierarchy For entities backed by [Nano.Data](#nanodata), pick the narrowest base class that matches the @@ -1984,7 +2020,8 @@ an eventing provider is actually registered, don't pass `IEventing?` into the `e | `roles/{id}/claims[/assign\|replace\|assign-or-replace\|remove]` | various | **administrator** | ⚠ **Security**: `password/reset/token` and `{id}/password/reset` are anonymous by design, for internal use. -Never expose this controller directly to untrusted clients without a Public API in front. +Never expose this controller directly to untrusted clients — it belongs on an internal service only, reached +through its Api Client, never added to an app playing the [Public API role](#public-api-vs-internal-service). #### Auth and audit controllers diff --git a/Nano.App.Api/README.md b/Nano.App.Api/README.md index f3be0e9f..845612cb 100644 --- a/Nano.App.Api/README.md +++ b/Nano.App.Api/README.md @@ -63,12 +63,16 @@ and simplifying the setup of new API applications. > ⚠️ Before proceeding, it is highly recommended to familiarize yourself generally with **[Nano Applications](https://github.com/Nano-Core/Nano.Library/blob/master/Nano.App#nanoapp)**. -The `NanoApiApplication` can operate as either an internal service or an externally accessible API. +The `NanoApiApplication` can operate as either an internal service or an externally accessible **Public API**. As an internal service, it can run behind your network boundary, handling requests from other applications within the system, using the built-in **[Nano Api Client](https://github.com/Nano-Core/Nano.Library/blob/master/Nano.App#api-clients)**. -When exposed as an external API, it sits behind an entry point that manages incoming traffic, providing controlled access to clients while keeping -the internal implementation consistent. This design allows the same application to function in both roles without changing its core configuration or service logic, -supporting flexible deployment scenarios. +As a Public API, it sits behind an entry point that manages incoming traffic, composing calls to one or more internal services into a response rather +than implementing business logic directly. The `NanoApiApplication` mechanism itself is identical either way, and switching a given application between +the two is a configuration change, not a rewrite - but the two roles are not interchangeable at the feature level. Some features (Identity, JWT/API-key +authentication) are internal-service-only and should never be added to a Public API, while others (a Data, Storage, or Eventing provider) are supported +but discouraged there, since a Public API is meant to stay a thin façade over the services it composes. See AGENTS.md's +**[Public API vs internal service](https://github.com/Nano-Core/Nano.Library/blob/master/AGENTS.md#public-api-vs-internal-service)** section for the +full breakdown of which features apply to which role. > 📖 Learn more about the overall Nano architecture here: **[Nano Architectures](https://github.com/Nano-Core/Nano.Library#%EF%B8%8F-nano-architectures)**. From cd6accf6dd928a2fbc80d64692b669680a1dc738 Mon Sep 17 00:00:00 2001 From: vivet Date: Fri, 18 Sep 2026 14:36:08 +0200 Subject: [PATCH 09/10] Fix login-refresh claim trust and add transient external-login refresh support. --- .../nano-add-authentication-jwt/SKILL.md | 68 +++++++++------ .../nano-add-authentication-jwt.prompt.md | 85 +++++++++++-------- AGENTS.md | 16 +++- .../Controllers/BaseAuthController.cs | 10 ++- .../Abstractions/IAuthTransientRepository.cs | 14 +-- .../Authentication/AuthTransientRepository.cs | 63 +++++++++++--- .../EndpointRouteBuilderExtensions.cs | 41 +++++++++ .../RegisterTransientAuthEndpointsTask.cs | 3 +- .../ConditionalActionsConvention.cs | 27 +++++- Nano.App.Api/README.md | 4 +- Nano.App/ApiClient/Apis/AuthApi.cs | 27 ++++++ ...aseLogInExternalTransientRefreshRequest.cs | 12 +++ ...ExternalTransientFacebookRefreshRequest.cs | 8 ++ ...InExternalTransientGoogleRefreshRequest.cs | 8 ++ ...xternalTransientMicrosoftRefreshRequest.cs | 8 ++ Nano.App/README.md | 1 + Nano.Common/Consts/ActionRoutes.cs | 5 ++ .../Helpers/TransientClaimsManifest.cs | 77 +++++++++++++++++ .../Authentication/IAuthIdentityRepository.cs | 8 +- .../Authentication/Models/LogInRefresh.cs | 25 ++---- .../Identity/Consts/ClaimTypesExtended.cs | 8 ++ .../Extensions/HttpContextExtensions.cs | 21 +---- .../Identity/Extensions/StringExtensions.cs | 27 ++++++ .../BaseAuthIdentityRepository.cs | 41 +++++++-- 24 files changed, 467 insertions(+), 140 deletions(-) create mode 100644 Nano.App/ApiClient/Requests/Auth/BaseLogInExternalTransientRefreshRequest.cs create mode 100644 Nano.App/ApiClient/Requests/Auth/LogInExternalTransientFacebookRefreshRequest.cs create mode 100644 Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRefreshRequest.cs create mode 100644 Nano.App/ApiClient/Requests/Auth/LogInExternalTransientMicrosoftRefreshRequest.cs create mode 100644 Nano.Data.Abstractions/Identity/Authentication/Helpers/TransientClaimsManifest.cs diff --git a/.claude/skills/nano-add-authentication-jwt/SKILL.md b/.claude/skills/nano-add-authentication-jwt/SKILL.md index c3c125cd..abb9001e 100644 --- a/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -137,38 +137,50 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) -**Stop and check this before scaffolding it — it's not always safe to add.** Nano auto-maps the -built-in transient external-login endpoint (`/auth/login/external/{provider}/transient`) whenever -*any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured — -see `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked -by type scan, not by whether this specific controller is the one deriving it. That endpoint binds -the request body straight into `LogInExternal` and merges its `TransientClaims`/ -`TransientRoles` **verbatim, with no server-side filtering,** into the minted JWT -(`AuthTransientRepository.LogInExternalAsync`). Concretely: once this app is in transient auth -(step 2) with any external login provider configured, adding this controller means **any -anonymous caller can post `{"transientClaims": {"IsAdmin": "true"}}` to that endpoint and receive -back a validly-signed token carrying that claim** — nothing here validates or restricts which -claims/roles a caller may assert about themselves. +**Stop and check this before scaffolding it — it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) — this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** — nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all — they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. - **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal - role, anything not meant to be caller-assignable) **on top of transient external login, don't - add this controller at all.** Write a custom controller instead (derive it from this app's own - base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/ - `IAuthTransientRepository` directly and builds the claims/roles itself from trusted data — never - from caller input. This is exactly what shields the app: `hasAuthController` stays `false`, so - Nano's own claim-forging endpoint is never mapped in the first place. This is a real, load-bearing - pattern in this codebase, not a hypothetical — see `Api.Admin`'s `AccountsController` (deriving - its own `BaseAdminController`), which implements `login/microsoft`/`login/refresh`/`me` by hand - for exactly this reason. + role, anything not meant to be caller-assignable) **at login, don't add this controller at all.** + Write a custom controller instead (derive it from this app's own base controller, *not* + `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ + `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data — never + from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical — see + `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements + `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. +- Nano additionally auto-maps built-in transient external-login endpoints + (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* + `BaseAuthController`-derived class exists in the app **and** no Identity is configured — see + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + type scan, not by whether this specific controller is the one deriving it. This is an extra + exposure specific to transient auth: it means a custom controller alone isn't enough to shield a + transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived + class anywhere in the app) — `Api.Admin`'s custom controller works precisely because it doesn't + derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but + isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either — anything that lets - a caller assign its own JWT claims is worth a deliberate decision, not a default. -- **Persistent auth (Identity present) does not have this problem** — `!hasIdentity` in the gate - above means the transient endpoint is never mapped once Identity is configured, regardless of - `AuthController`/external login. This warning is specific to the transient-auth shape. -- If none of the above applies — persistent auth, or transient auth with no need for - server-computed claims beyond what the external provider itself asserts — the generic controller - below is fine as-is. + a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies — no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims — the generic controller below is fine as-is. `Controllers/AuthController.cs`, main app project: diff --git a/.github/prompts/nano-add-authentication-jwt.prompt.md b/.github/prompts/nano-add-authentication-jwt.prompt.md index ef5dd7a5..dacb9124 100644 --- a/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -3,6 +3,7 @@ mode: agent description: Configure Nano's built-in JWT authentication (App:Authentication:Jwt) on a Nano.Library-based API/Web application - adds the Jwt configuration, the AuthController, the Development key setup, and (for the token-issuing app) the Staging/Production key-generation and Kubernetes secret. Use when the user asks to add login, sign-in, or JWT authentication to a Nano API or Web application - not for adding a user store by itself (that's nano-add-identity) or for API-key authentication by itself (that's nano-add-authentication-apikey, which works standalone without any of this). --- + # Nano add JWT authentication Configures Nano's built-in JWT authentication on an existing Nano API/Web application. Read @@ -36,17 +37,17 @@ repository backs a given external login in that case - not repeated here. ## Before making any change, determine 1. **Does this app issue tokens, or only validate them?** Ask if the request doesn't say. - - **Issuer**: needs both `PublicKey` and `PrivateKey` in Staging/Production, and creates the + - **Issuer**: needs both `PublicKey` and `PrivateKey` in Staging/Production, and creates the `auth-jwt-secret` Kubernetes secret from real GitHub secrets. - - **Validator-only**: needs only `PublicKey` in Staging/Production, and must **not** create or + - **Validator-only**: needs only `PublicKey` in Staging/Production, and must **not** create or re-apply the secret - it references the one the issuer app already created. See the Kubernetes section below; getting this backwards silently corrupts the shared secret with unexpanded placeholder values - a real bug found and fixed this way in this codebase, so don't repeat it. - - This distinction **does not apply to Development** - see below. + - This distinction **does not apply to Development** - see below. 2. **Persistent or transient auth?** Check whether [Identity](nano-add-identity) (`Data:Identity`) is already configured. - - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs + - **Persistent** (Identity present): `AuthIdentityRepository` auto-populates and backs `/auth/login`, `/auth/login/refresh`, `/auth/logout` - nothing further to wire beyond the `Jwt` config and controller below. **This combination (persistent auth + `AuthController`) is an internal-service-only pattern** - per AGENTS.md's [Controllers § Public API vs @@ -55,14 +56,14 @@ repository backs a given external login in that case - not repeated here. be a Public API, stop: it shouldn't have Identity here at all - see `nano-add-identity`'s own warning on this, and point the user at composing through the owning internal service's Api Client instead. - - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ + - **Transient** (no Identity): needs `Jwt.ExternalLogins` configured (built-in Facebook/ Google/Microsoft, or a custom provider - see "External Login" below) - ask which, and whether a custom provider implementation is needed, before proceeding. **Also ask whether this app needs to assert its own server-computed claims/roles on top of the external login** (e.g. an `IsAdmin` flag) - if so, see the "AuthController" section's warning below before scaffolding a generic `AuthController`; adding one unconditionally here can open a caller-controlled claim-injection endpoint. - - If the user wants persistent auth but Identity isn't registered yet, stop and point them at + - If the user wants persistent auth but Identity isn't registered yet, stop and point them at `nano-add-identity` first. 3. **Is Authentication already configured?** Check the base `appsettings.json` for `App:Authentication:Jwt`, or an existing `AuthController`. If present, say so before changing @@ -79,10 +80,10 @@ repository backs a given external login in that case - not repeated here. `IAuthRepository` without `Jwt` configured, so it genuinely didn't exist yet). Adding `Jwt` now changes two things **automatically, from config alone** - nothing extra to build, but tell the user about both: - - Nano's scheme selection (`AddNanoAuthentication`, based on whether `Jwt`/`ApiKeyOptions` are + - Nano's scheme selection (`AddNanoAuthentication`, based on whether `Jwt`/`ApiKeyOptions` are each present) switches from API-key-only to `JWT_OR_APIKEY` - existing API-key callers keep working unchanged, requests carrying a JWT `Authorization` header now also work. - - The `AuthController` this skill adds will immediately expose `/auth/login/apikey` (its + - The `AuthController` this skill adds will immediately expose `/auth/login/apikey` (its visibility is gated purely on `Data:Identity:ApiKey:Secret` being set, per `ConditionalActionsConvention`) - callers can now trade an API key for a JWT once instead of presenting the key on every request. @@ -137,38 +138,50 @@ overrides, no keys (those come from the Kubernetes secret, never a static file): ## AuthController (API/Web only) -**Stop and check this before scaffolding it - it's not always safe to add.** Nano auto-maps the -built-in transient external-login endpoint (`/auth/login/external/{provider}/transient`) whenever -*any* `BaseAuthController`-derived class exists in the app **and** no Identity is configured - -see `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked -by type scan, not by whether this specific controller is the one deriving it. That endpoint binds -the request body straight into `LogInExternal` and merges its `TransientClaims`/ -`TransientRoles` **verbatim, with no server-side filtering,** into the minted JWT -(`AuthTransientRepository.LogInExternalAsync`). Concretely: once this app is in transient auth -(step 2) with any external login provider configured, adding this controller means **any -anonymous caller can post `{"transientClaims": {"IsAdmin": "true"}}` to that endpoint and receive -back a validly-signed token carrying that claim** - nothing here validates or restricts which -claims/roles a caller may assert about themselves. +**Stop and check this before scaffolding it - it's not always safe to add.** `BaseAuthController`'s +`login`/`login/external` actions bind `TransientClaims`/`TransientRoles` straight from the request +body and merge them **verbatim, with no server-side filtering,** into the minted JWT +(`AuthTransientRepository.LogInExternalAsync`/`BaseAuthIdentityRepository.LogInAsync`/ +`LogInExternalAsync`) - this applies to **both persistent and transient auth**, not just transient. +Concretely: adding this controller means **any caller who can reach it can post +`{"transientClaims": {"IsAdmin": "true"}}` at login and receive back a validly-signed token carrying +that claim** - nothing here validates or restricts which claims/roles a caller may assert about +themselves. Refresh does not have this problem: `login/refresh`/the transient external-login +refresh never accept claims/roles from the caller at all - they're always recovered from a manifest +claim embedded at login, so a refresh can never grant more than the original login already did (see +`ClaimTypesExtended.TransientClaimsManifest`/the internal `TransientClaimsManifest` class in +`Nano.Data.Abstractions`). The transient refresh endpoint +(`/auth/login/external/{providerName}/transient/refresh`) also takes no request body at all - the +token being refreshed comes from the Authorization header. The risk below is specific to login, and +to whoever can reach `AuthController` at all. - **If this app needs to compute its own claims server-side** (an `IsAdmin` flag, an internal - role, anything not meant to be caller-assignable) **on top of transient external login, don't - add this controller at all.** Write a custom controller instead (derive it from this app's own - base controller, *not* `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/ - `IAuthTransientRepository` directly and builds the claims/roles itself from trusted data - never - from caller input. This is exactly what shields the app: `hasAuthController` stays `false`, so - Nano's own claim-forging endpoint is never mapped in the first place. This is a real, load-bearing - pattern in this codebase, not a hypothetical - see `Api.Admin`'s `AccountsController` (deriving - its own `BaseAdminController`), which implements `login/microsoft`/`login/refresh`/`me` by hand - for exactly this reason. + role, anything not meant to be caller-assignable) **at login, don't add this controller at all.** + Write a custom controller instead (derive it from this app's own base controller, *not* + `BaseAuthController`) that calls `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository`/ + `IAuthIdentityRepository` directly and builds the claims/roles itself from trusted data - never + from caller input. This is a real, load-bearing pattern in this codebase, not a hypothetical - see + `Api.Admin`'s `AccountsController` (deriving its own `BaseAdminController`), which implements + `login/microsoft`/`login/refresh`/`me` by hand for exactly this reason. +- Nano additionally auto-maps built-in transient external-login endpoints + (`/auth/login/external/{provider}/transient` and its `/refresh` counterpart) whenever *any* + `BaseAuthController`-derived class exists in the app **and** no Identity is configured - see + `ServiceScopeExtensions.UseNanoEndpoints`'s `!hasIdentity && hasAuthController` gate, checked by + type scan, not by whether this specific controller is the one deriving it. This is an extra + exposure specific to transient auth: it means a custom controller alone isn't enough to shield a + transient app unless `hasAuthController` also stays `false` (i.e. no `BaseAuthController`-derived + class anywhere in the app) - `Api.Admin`'s custom controller works precisely because it doesn't + derive `BaseAuthController`. The `/refresh` counterpart is auto-mapped under the same gate, but + isn't a caller-trust risk the way login is - see above. - **This risk is sharpest on a publicly-exposed app** (anyone on the internet can reach the endpoint), but don't treat an internal-only app as automatically safe either - anything that lets - a caller assign its own JWT claims is worth a deliberate decision, not a default. -- **Persistent auth (Identity present) does not have this problem** - `!hasIdentity` in the gate - above means the transient endpoint is never mapped once Identity is configured, regardless of - `AuthController`/external login. This warning is specific to the transient-auth shape. -- If none of the above applies - persistent auth, or transient auth with no need for - server-computed claims beyond what the external provider itself asserts - the generic controller - below is fine as-is. + a caller assign its own JWT claims is worth a deliberate decision, not a default. `AuthController` + is an internal-service-only pattern to begin with (see AGENTS.md's [Controllers § Public API vs + internal service](#public-api-vs-internal-service)), so "internal-only" is the floor, not a reason + to skip the decision. +- If none of the above applies - no need for server-computed claims beyond what the external + provider itself asserts, and whoever can reach this app's `AuthController` is already trusted to + assert login-time claims - the generic controller below is fine as-is. `Controllers/AuthController.cs`, main app project: diff --git a/AGENTS.md b/AGENTS.md index 1210c866..10e15470 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,7 +209,7 @@ Available as properties on the client instance — no implementation needed, jus | Group | Available on | Covers | | -------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | | `.Entity` | `BaseApiClient` | Full CRUD against any entity of the target app: `GetAsync`, `GetManyAsync`, `QueryAsync`, `QueryFirstAsync`, `QueryCountAsync`, `CreateAsync`/`CreateOrEditAsync`/`CreateOrGetAsync`/`CreateAndGetAsync`/`CreateManyAsync`(`Bulk`), `EditAsync`/`EditAndGetAsync`/`EditManyAsync`(`Bulk`)/`EditQueryAsync`(`Bulk`), `DeleteAsync`/`DeleteManyAsync`(`Bulk`)/`DeleteQueryAsync`(`Bulk`). Mirrors the entity controller route table 1:1 — see [Controllers § Full CRUD route table](#full-crud-route-table). | -| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | +| `.Auth` | `BaseApiClient` | `LogInAsync`, `LogInRootAsync`, `LogInApiKeyAsync`, `LogInExternalAsync`, `LogInExternalTransientAsync`, `LogInExternalTransientRefreshAsync`, `LogInRefreshAsync`, `LogOutAsync`, `GetExternalSchemesAsync`. | | `.Audit` | `BaseApiClient` | Read-only access to the target's `AuditEntry` log: `GetAsync`/`GetManyAsync`/`QueryAsync`/`QueryFirstAsync`/`QueryCountAsync`. | | `.Identity` | `BaseIdentityApiClient` | Sign-up, password set/change/reset (+ token generation), email/phone change/confirm (+ token generation), roles, claims, external logins, refresh tokens, API keys. | @@ -1614,7 +1614,7 @@ are all nullable — each is populated only if the matching config exists, and t | ---------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `AuthRootRepository` | `Jwt.RootLogin` configured | `/auth/login/root` | | `AuthIdentityRepository` | [Data Identity](#identity) configured | `/auth/login`, `/auth/login/apikey`, `/auth/login/refresh`, `/auth/logout` | -| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient` | +| `AuthTransientRepository` | `Jwt.ExternalLogins` configured, Identity **not** configured | `/auth/login/external/{providerName}/transient`, `/auth/login/external/{providerName}/transient/refresh` | | `AuthExternalRepositoryAggregator` | Always available | `/auth/external/schemes`, external login resolution for both identity and transient repositories | ⚠ **`AuthTransientRepository`'s endpoint trusts the caller.** `/auth/login/external/{providerName}/transient` @@ -1627,7 +1627,17 @@ across the whole app, not by which controller you meant to use it for). A transi server-computed claims on top of external login (an admin flag, an internal role) must **not** derive a generic `BaseAuthController`-based controller — implement a custom controller instead (deriving this app's own base controller), calling `IAuthExternalRepositoryAggregator`/`IAuthTransientRepository` directly and computing -claims/roles only from trusted server-side data, never from caller input. +claims/roles only from trusted server-side data, never from caller input. This same caller-trust +problem applies at login on `AuthIdentityRepository` too (`/auth/login`/`/auth/login/external`), not +just the transient endpoint — refresh is the exception: `/auth/login/refresh` and +`/auth/login/external/{providerName}/transient/refresh` (auto-mapped under the same +`!hasIdentity && hasAuthController` gate as the login endpoint above) never accept claims/roles from +the caller at all, they're recovered from a manifest claim embedded at login +(`ClaimTypesExtended.TransientClaimsManifest`, built/read via the internal `TransientClaimsManifest` +class in `Nano.Data.Abstractions`), so a refresh can never grant more than the original login already +did. The transient refresh endpoint also takes no request body at all — the token being refreshed is +read from the Authorization header, and the external provider's own refresh token is recovered from +that same token's claims, never supplied by the caller. ##### Custom external provider diff --git a/Nano.App.Api/Controllers/BaseAuthController.cs b/Nano.App.Api/Controllers/BaseAuthController.cs index ddf265f2..278a476c 100644 --- a/Nano.App.Api/Controllers/BaseAuthController.cs +++ b/Nano.App.Api/Controllers/BaseAuthController.cs @@ -195,8 +195,16 @@ public virtual async Task LogInRefreshAsync([FromBody][Required] return this.NotFound(); } + var token = this.HttpContext + .GetJwtToken(); + + if (token == null) + { + return this.Unauthorized(); + } + var accessToken = await this.authRepository.AuthIdentityRepository - .LogInRefreshAsync(logInRefresh, cancellationToken); + .LogInRefreshAsync(token, logInRefresh.RefreshToken, cancellationToken); return this.Ok(accessToken); } diff --git a/Nano.App.Api/Mvc/Authentication/Abstractions/IAuthTransientRepository.cs b/Nano.App.Api/Mvc/Authentication/Abstractions/IAuthTransientRepository.cs index 1c9d2b88..7537920f 100644 --- a/Nano.App.Api/Mvc/Authentication/Abstractions/IAuthTransientRepository.cs +++ b/Nano.App.Api/Mvc/Authentication/Abstractions/IAuthTransientRepository.cs @@ -47,14 +47,18 @@ Task LogInExternalAsync(string providerName, LogInExternal - /// Refreshes an external login using the provider's refresh token and generates a new corresponding JWT access token. + /// Refreshes a transient external login and generates a new corresponding JWT access token. The + /// provider's own refresh token is never supplied by the caller - it is recovered from a claim embedded + /// in at login, since a transient login has no other store to keep it in. + /// Transient claims/roles are recovered the same way, so a refresh can never grant more than the + /// original login already did. /// /// The name of the provider. - /// The refresh information, including the expired access token, the provider's refresh token, and transient claims/roles to apply to the new token. + /// The expired or soon-to-expire access token, read from the caller's Authorization header. /// A to cancel the operation. /// A task that represents the asynchronous operation. The task result contains a new for the authenticated external user. - /// Thrown if is null. + /// Thrown if is null. /// Thrown if the underlying external repository is not configured. - /// Thrown if 's token fails validation, or if the refresh fails with the external provider. - Task LogInExternalRefreshAsync(string providerName, LogInRefresh logInRefresh, CancellationToken cancellationToken = default); + /// Thrown if fails validation, does not carry a refreshable external login, or the refresh fails with the external provider. + Task LogInExternalRefreshAsync(string providerName, string token, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/Nano.App.Api/Mvc/Authentication/AuthTransientRepository.cs b/Nano.App.Api/Mvc/Authentication/AuthTransientRepository.cs index 60e06c60..9de77dfd 100644 --- a/Nano.App.Api/Mvc/Authentication/AuthTransientRepository.cs +++ b/Nano.App.Api/Mvc/Authentication/AuthTransientRepository.cs @@ -1,7 +1,8 @@ -using Microsoft.AspNetCore.Identity; using Nano.App.Api.Mvc.Authentication.Abstractions; +using Nano.Data.Abstractions.Exceptions; using Nano.Data.Abstractions.Extensions; using Nano.Data.Abstractions.Identity.Authentication; +using Nano.Data.Abstractions.Identity.Authentication.Helpers; using Nano.Data.Abstractions.Identity.Authentication.Models; using Nano.Data.Abstractions.Identity.Consts; using Nano.Data.Abstractions.Identity.Extensions; @@ -41,6 +42,9 @@ public virtual async Task LogInExternalAsync(LogInExternal logInExt var roleClaims = logInExternal.TransientRoles .Select(x => new Claim(ClaimTypes.Role, x)); + var manifestClaim = TransientClaimsManifest + .Build(logInExternal.TransientRoles, logInExternal.TransientClaims); + var accessToken = this.authJwtRepository .GenerateJwtToken(new GenerateJwtToken { @@ -51,6 +55,7 @@ public virtual async Task LogInExternalAsync(LogInExternal logInExt ExternalToken = logInExternal.ExternalAuthenticationData.ExternalToken, Claims = claims .Union(roleClaims) + .Append(manifestClaim) }); return accessToken; @@ -85,10 +90,10 @@ public virtual async Task LogInExternalAsync(string provider } /// - public virtual async Task LogInExternalRefreshAsync(string providerName, LogInRefresh logInRefresh, CancellationToken cancellationToken = default) + public virtual async Task LogInExternalRefreshAsync(string providerName, string jwtToken, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(providerName); - ArgumentNullException.ThrowIfNull(logInRefresh); + ArgumentNullException.ThrowIfNull(jwtToken); if (this.authExternalRepository == null) { @@ -96,24 +101,57 @@ public virtual async Task LogInExternalRefreshAsync(string provider } this.authJwtRepository - .ValidateTokenForRefresh(logInRefresh.Token); - - var externalAuthenticationToken = await this.authExternalRepository - .AuthenticateRefreshAsync(providerName, logInRefresh.RefreshToken, cancellationToken); + .ValidateTokenForRefresh(jwtToken); var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); + var jwtSecurityToken = jwtSecurityTokenHandler + .ReadJwtToken(jwtToken); + + var tokenProviderName = jwtSecurityToken.Claims + .Where(x => x.Type == ClaimTypesExtended.ExternalProviderName) + .Select(x => x.Value) + .FirstOrDefault(); + + var externalProviderRefreshToken = jwtSecurityToken.Claims + .Where(x => x.Type == ClaimTypesExtended.ExternalProviderRefreshToken) + .Select(x => x.Value) + .FirstOrDefault(); + + if (string.IsNullOrEmpty(tokenProviderName) || string.IsNullOrEmpty(externalProviderRefreshToken)) + { + throw new UnauthorizedException("The token does not carry a refreshable external login."); + } + + if (!string.Equals(tokenProviderName, providerName, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedException($"The token was issued for provider '{tokenProviderName}', not '{providerName}'."); + } + + var externalAuthenticationToken = await this.authExternalRepository + .AuthenticateRefreshAsync(providerName, externalProviderRefreshToken, cancellationToken); + var appId = jwtSecurityTokenHandler - .GetJwtAppId(logInRefresh.Token) ?? IdentityDefaults.DEFAULT_APP_ID; + .GetJwtAppId(jwtToken) ?? IdentityDefaults.DEFAULT_APP_ID; var userId = jwtSecurityTokenHandler - .GetJwtUserId(logInRefresh.Token); + .GetJwtUserId(jwtToken); var userName = jwtSecurityTokenHandler - .GetJwtUserName(logInRefresh.Token); + .GetJwtUserName(jwtToken); var email = jwtSecurityTokenHandler - .GetJwtUserEmail(logInRefresh.Token); + .GetJwtUserEmail(jwtToken); + + var (transientRoles, transientClaims) = TransientClaimsManifest + .Parse(jwtSecurityToken.Claims); + + var transientClaimsManifest = TransientClaimsManifest.Build(transientRoles, transientClaims); + + var claims = transientClaims + .Select(x => new Claim(x.Key, x.Value)) + .Union(transientRoles.Select(x => new Claim(ClaimTypes.Role, x))) + .Append(transientClaimsManifest); var accessToken = this.authJwtRepository .GenerateJwtToken(new GenerateJwtToken @@ -123,8 +161,7 @@ public virtual async Task LogInExternalRefreshAsync(string provider UserName = userName, UserEmail = email, ExternalToken = externalAuthenticationToken, - Claims = logInRefresh.TransientClaims - .Select(x => new Claim(x.Key, x.Value)) + Claims = claims }); return accessToken; diff --git a/Nano.App.Api/Mvc/Authentication/Extensions/EndpointRouteBuilderExtensions.cs b/Nano.App.Api/Mvc/Authentication/Extensions/EndpointRouteBuilderExtensions.cs index f3e3be67..09ed24a7 100644 --- a/Nano.App.Api/Mvc/Authentication/Extensions/EndpointRouteBuilderExtensions.cs +++ b/Nano.App.Api/Mvc/Authentication/Extensions/EndpointRouteBuilderExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Nano.App.Api.Mvc.Authentication.Abstractions; using Nano.Common.Consts; @@ -45,4 +46,44 @@ async Task LogInExternalTransientAsync(LogInExternal request, IA return Results.Ok(accessToken); } } + + internal static IEndpointRouteBuilder MapEndpointAuthTransientRefresh(this IEndpointRouteBuilder builder, string providerName, string version, string root) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(providerName); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(root); + + var route = ActionRoutes.AUTH_LOGIN_EXTERNAL_TRANSIENT_REFRESH + .Replace("{providerName}", providerName.ToLower()); + + var summary = $"Refreshes a transient external {providerName} login."; + const string TAG = ControllerRoutes.AUTH; + + builder + .MapPost($"{root}/{TAG.ToLower()}/{route}", LogInExternalTransientRefreshAsync) + .WithEndpointDefaults(summary, TAG, version, true); + + builder + .MapPost($"{root}/{ControllerRoutes.ROUTE_VERSION_PREFIX}/{TAG.ToLower()}/{route}", LogInExternalTransientRefreshAsync) + .WithEndpointDefaults(summary, TAG, version, true); + + return builder; + + async Task LogInExternalTransientRefreshAsync([FromHeader(Name = "Authorization")] string? authorization, IAuthTransientRepository authTransientRepository, CancellationToken cancellationToken) + { + var token = authorization + .GetJwtToken(); + + if (token == null) + { + return Results.Unauthorized(); + } + + var accessToken = await authTransientRepository + .LogInExternalRefreshAsync(providerName, token, cancellationToken); + + return Results.Ok(accessToken); + } + } } \ No newline at end of file diff --git a/Nano.App.Api/Mvc/Authentication/RegisterTransientAuthEndpointsTask.cs b/Nano.App.Api/Mvc/Authentication/RegisterTransientAuthEndpointsTask.cs index 53cc6497..960c4883 100644 --- a/Nano.App.Api/Mvc/Authentication/RegisterTransientAuthEndpointsTask.cs +++ b/Nano.App.Api/Mvc/Authentication/RegisterTransientAuthEndpointsTask.cs @@ -59,6 +59,7 @@ private static void MapAuthEndpoints(IEndpointRouteBuilder builder, strin ArgumentNullException.ThrowIfNull(root); builder - .MapEndpointAuthTransient(providerName, version, root); + .MapEndpointAuthTransient(providerName, version, root) + .MapEndpointAuthTransientRefresh(providerName, version, root); } } \ No newline at end of file diff --git a/Nano.App.Api/Mvc/Conventions/ConditionalActionsConvention.cs b/Nano.App.Api/Mvc/Conventions/ConditionalActionsConvention.cs index d65be644..11e707e2 100644 --- a/Nano.App.Api/Mvc/Conventions/ConditionalActionsConvention.cs +++ b/Nano.App.Api/Mvc/Conventions/ConditionalActionsConvention.cs @@ -25,7 +25,9 @@ public void Apply(ControllerModel controller) this.DisableAuthControllerActions(controller); this.DisableEntityUserControllerActions(controller); + this.WarnIfAnonymousPasswordResetExposed(controller); + this.WarnIfAuthControllerExposesUnfilteredLogin(controller); } @@ -241,8 +243,29 @@ private void WarnIfAnonymousPasswordResetExposed(ControllerModel controller) } const string MESSAGE = - "Controller '{ControllerName}' derives from 'BaseEntityUserController' and exposes unauthenticated password-reset endpoints (password/reset/token and {{id}}/password/reset). " + - "These issue and consume reset tokens with no auth, allowing full account takeover for any known username. They are for internal services only and must never be reachable outside a trusted network."; + "Controller '{ControllerName}' derives from 'BaseEntityUserController' and exposes unauthenticated password-reset endpoints (password/reset/token and " + + "{{id}}/password/reset). These issue and consume reset tokens with no auth, allowing full account takeover for any known username. They are for internal services " + + "only and must never be reachable outside a trusted network."; + + this.logger + .LogWarning(MESSAGE, controller.ControllerType.FullName); + } + private void WarnIfAuthControllerExposesUnfilteredLogin(ControllerModel controller) + { + ArgumentNullException.ThrowIfNull(controller); + + var isAuthController = controller.ControllerType + .IsTypeOf(typeof(BaseAuthController<>)); + + if (!isAuthController) + { + return; + } + + const string MESSAGE = + "Controller '{ControllerName}' derives from 'BaseAuthController', whose login endpoints (and, for transient auth, Nano's auto-mapped external-login endpoint) " + + "bind TransientClaims/TransientRoles straight from the request with no server-side filtering - any caller can assert arbitrary claims/roles. " + + "Use a custom controller instead if this app needs server-computed claims."; this.logger .LogWarning(MESSAGE, controller.ControllerType.FullName); diff --git a/Nano.App.Api/README.md b/Nano.App.Api/README.md index 845612cb..bfac9755 100644 --- a/Nano.App.Api/README.md +++ b/Nano.App.Api/README.md @@ -1780,7 +1780,7 @@ The `IAuthIdentityRepository` provides the following methods to support this fun | `LogInAsync` | logIn | Logs in a user using username and password credentials, generating a JWT access token and optional refresh token. | | `LogInExternalAsync` | logInExternal | Logs in a user using direct external login data, generating a JWT access token and optional refresh token. | | `LogInExternalAsync` | providerName, logInExternalFlow | Logs in a user authenticating with a configured external login provider flow, generating a JWT access token and optional refresh token. | -| `LogInRefreshAsync` | logInRefresh | Refreshes an existing access token using a valid refresh token, generating a new JWT and refresh token. | +| `LogInRefreshAsync` | token, refreshToken | Refreshes an existing access token using a valid refresh token, generating a new JWT and refresh token. `token` is the expired/soon-to-expire access token, read by the controller from the Authorization header, not the request body. | | `LogOutAsync` | userId, appId | Logs out the current user. | Try it out yourself using the **[Api.Data.Identity.Auth.Jwt](https://github.com/Nano-Core/Nano.Lessons/blob/master/Api.Data.Identity.Auth.Jwt)** example. @@ -1792,6 +1792,7 @@ also supports external authentication but is designed for transient logins witho | ----------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `LogInExternalAsync` | logInExternal | Performs an external login using direct external login data and generates a corresponding JWT access token. | | `LogInExternalAsync` | logInExternalDirect | Performs an external login using a configured built-in external provider type and generates a corresponding JWT access token. | +| `LogInExternalRefreshAsync` | providerName, token | Refreshes a transient external login. `token` is the expired/soon-to-expire access token, read from the Authorization header - the provider's own refresh token and any transient claims/roles are recovered from claims embedded in `token` at login, never supplied by the caller. | Logging in using external authentication in Nano can be achieved either by configuring a built-in provider or by implementing a custom provider (see further down). @@ -2333,6 +2334,7 @@ are not configured will not be registered or available in the controller. | `/auth/login/apikey` | POST | Anonymous | Authenticates the user using `X-Api-Key` header value and returns an access token. Only exposed when Identity ApiKeys has been configured. | | `/auth/login/external/{providerName}` | POST | Anonymous | Signs in a user using external provider authentication. An endpoint is exposed for each registered external provider. Only exposed when Identity has been configured. | | `/auth/login/external/{providerName}/transient` | POST | Anonymous | Signs in a transient user using external provider authentication. An endpoint is exposed for each registered external provider. Only exposed when Identity has not been configured. | +| `/auth/login/external/{providerName}/transient/refresh` | POST | Anonymous | Refreshes a transient external provider login. No request body - the token is read from the Authorization header. An endpoint is exposed for each registered external provider. Only exposed when Identity has not been configured. | | `/auth/login/refresh` | POST | Anonymous | Refreshes an existing access token. | | `/auth/logout` | POST | Anonymous | Logs out the current user. | | `/auth/external/schemes` | GET | Anonymous | Retrieves all configured external authentication methods (e.g., Google, Facebook). Only exposed when at least one external authentication provider has been registerd. | diff --git a/Nano.App/ApiClient/Apis/AuthApi.cs b/Nano.App/ApiClient/Apis/AuthApi.cs index 8cfd8a46..21af2a96 100644 --- a/Nano.App/ApiClient/Apis/AuthApi.cs +++ b/Nano.App/ApiClient/Apis/AuthApi.cs @@ -164,6 +164,33 @@ public async Task LogInExternalTransientAsync(TReq return response; } + /// + /// Executes auth/login/external/{providerName}/transient/refresh to refresh a transient + /// external-login access token. Sets the authorization header on success. + /// + /// The transient external login refresh request type. + /// The transient external login refresh request. + /// The cancellation token. + /// The refreshed access token. + /// Thrown if refresh fails. + public async Task LogInExternalTransientRefreshAsync(TRequest request, CancellationToken cancellationToken = default) + where TRequest : BaseLogInExternalTransientRefreshRequest + { + ArgumentNullException.ThrowIfNull(request); + + var response = await this.api + .InvokeAsync(request, cancellationToken); + + if (response == null) + { + throw new UnauthorizedException(); + } + + this.SetAuthorizationHeader(response.Token); + + return response; + } + /// /// Executes auth/login/refresh to refresh an access token. /// Sets the authorization header on success. diff --git a/Nano.App/ApiClient/Requests/Auth/BaseLogInExternalTransientRefreshRequest.cs b/Nano.App/ApiClient/Requests/Auth/BaseLogInExternalTransientRefreshRequest.cs new file mode 100644 index 00000000..c1b24c06 --- /dev/null +++ b/Nano.App/ApiClient/Requests/Auth/BaseLogInExternalTransientRefreshRequest.cs @@ -0,0 +1,12 @@ +using Nano.App.ApiClient.Annotations.Actions; +using Nano.Common.Consts; + +namespace Nano.App.ApiClient.Requests.Auth; + +/// +/// Base class for transient external login refresh requests. Carries no body - the access token being +/// refreshed is sent via the Authorization header (forwarded automatically by the api client), not the +/// request body, and the provider's own refresh token is recovered server-side from that same token. +/// +[PostAction(ActionRoutes.AUTH_LOGIN_EXTERNAL_TRANSIENT_REFRESH)] +public abstract class BaseLogInExternalTransientRefreshRequest(string providerName) : BaseLogInExternalRequest(providerName); diff --git a/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientFacebookRefreshRequest.cs b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientFacebookRefreshRequest.cs new file mode 100644 index 00000000..a92506a6 --- /dev/null +++ b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientFacebookRefreshRequest.cs @@ -0,0 +1,8 @@ +using Nano.Data.Abstractions.Identity.Authentication.Consts; + +namespace Nano.App.ApiClient.Requests.Auth; + +/// +/// Class for Facebook transient external login refresh requests. +/// +public class LogInExternalTransientFacebookRefreshRequest() : BaseLogInExternalTransientRefreshRequest(BuiltInExternalLogInProviderNames.FACEBOOK); diff --git a/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRefreshRequest.cs b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRefreshRequest.cs new file mode 100644 index 00000000..6dd4a247 --- /dev/null +++ b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRefreshRequest.cs @@ -0,0 +1,8 @@ +using Nano.Data.Abstractions.Identity.Authentication.Consts; + +namespace Nano.App.ApiClient.Requests.Auth; + +/// +/// Class for Google transient external login refresh requests. +/// +public class LogInExternalTransientGoogleRefreshRequest() : BaseLogInExternalTransientRefreshRequest(BuiltInExternalLogInProviderNames.GOOGLE); diff --git a/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientMicrosoftRefreshRequest.cs b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientMicrosoftRefreshRequest.cs new file mode 100644 index 00000000..4cb2221b --- /dev/null +++ b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientMicrosoftRefreshRequest.cs @@ -0,0 +1,8 @@ +using Nano.Data.Abstractions.Identity.Authentication.Consts; + +namespace Nano.App.ApiClient.Requests.Auth; + +/// +/// Class for Microsoft transient external login refresh requests. +/// +public class LogInExternalTransientMicrosoftRefreshRequest() : BaseLogInExternalTransientRefreshRequest(BuiltInExternalLogInProviderNames.MICROSOFT); diff --git a/Nano.App/README.md b/Nano.App/README.md index c4072592..501d002c 100644 --- a/Nano.App/README.md +++ b/Nano.App/README.md @@ -213,6 +213,7 @@ The following methods are available for Auth operations. | `LogInApiKeyAsync` | LogInApiKeyRequest | Executes `auth/login/apikey` to authenticate using an API key. | | `LogInExternalAsync` | BaseLogInExternalRequest | Executes `auth/login/external` to authenticate via an external provider. | | `LogInExternalTransientAsync` | BaseLogInExternalRequest | Executes `auth/login/external/transient` using a transient external flow. | +| `LogInExternalTransientRefreshAsync` | BaseLogInExternalTransientRefreshRequest | Executes `auth/login/external/{providerName}/transient/refresh` to refresh a transient external login. | | `LogInRefreshAsync` | LogInRefreshRequest | Executes `auth/login/refresh` to refresh an access token. | | `LogOutAsync` | - | Executes `auth/logout` to invalidate the current session or token. | diff --git a/Nano.Common/Consts/ActionRoutes.cs b/Nano.Common/Consts/ActionRoutes.cs index 2fabfdbe..599a90e6 100644 --- a/Nano.Common/Consts/ActionRoutes.cs +++ b/Nano.Common/Consts/ActionRoutes.cs @@ -157,6 +157,11 @@ public class ActionRoutes /// public const string AUTH_LOGIN_EXTERNAL_TRANSIENT = "login/external/{providerName}/transient"; + /// + /// Route for refreshing a transient external provider login. + /// + public const string AUTH_LOGIN_EXTERNAL_TRANSIENT_REFRESH = "login/external/{providerName}/transient/refresh"; + /// /// Route for refreshing authentication tokens. /// diff --git a/Nano.Data.Abstractions/Identity/Authentication/Helpers/TransientClaimsManifest.cs b/Nano.Data.Abstractions/Identity/Authentication/Helpers/TransientClaimsManifest.cs new file mode 100644 index 00000000..2635e973 --- /dev/null +++ b/Nano.Data.Abstractions/Identity/Authentication/Helpers/TransientClaimsManifest.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Nano.Data.Abstractions.Identity.Consts; + +namespace Nano.Data.Abstractions.Identity.Authentication.Helpers; + +/// +/// Builds and reads the manifest claim recording exactly which non-persisted "transient" roles/claims +/// were asserted at login, so a refresh can recover and carry forward that same set instead of trusting +/// the refresh caller to resupply it - a refresh can then never grant more than the original login did. +/// Internal to Nano.Library's own login/refresh implementations, not a public extension point. +/// +public static class TransientClaimsManifest +{ + /// + /// Builds the manifest claim for the given transient roles/claims, to embed alongside the real + /// claims on every issued token - including on refresh, so the manifest survives the whole refresh + /// chain. The value is base64-encoded JSON: opaque by convention, not meant to be read directly. + /// + /// The transient roles asserted at login. + /// The transient claims asserted at login. + /// The manifest claim. + public static Claim Build(IEnumerable transientRoles, IEnumerable> transientClaims) + { + ArgumentNullException.ThrowIfNull(transientRoles); + ArgumentNullException.ThrowIfNull(transientClaims); + + var entries = transientRoles + .Select(x => new[] { ClaimTypes.Role, x }) + .Concat(transientClaims + .Select(x => new[] { x.Key, x.Value })); + + var json = JsonSerializer.Serialize(entries); + var value = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + return new Claim(ClaimTypesExtended.TransientClaimsManifest, value); + } + + /// + /// Reads the transient roles and claims back out of a manifest claim previously produced by + /// , splitting role entries back out from plain claims. Returns two empty + /// collections if no manifest claim is present. + /// + /// The claims of the token being refreshed. + /// The recovered transient roles and claims. + public static (IEnumerable TransientRoles, IEnumerable> TransientClaims) Parse(IEnumerable claims) + { + ArgumentNullException.ThrowIfNull(claims); + + var value = claims + .Where(x => x.Type == ClaimTypesExtended.TransientClaimsManifest) + .Select(x => x.Value) + .FirstOrDefault(); + + if (string.IsNullOrEmpty(value)) + { + return ([], []); + } + + var json = Encoding.UTF8.GetString(Convert.FromBase64String(value)); + var entries = JsonSerializer.Deserialize(json) ?? []; + + var transientRoles = entries + .Where(x => x[0] == ClaimTypes.Role) + .Select(x => x[1]); + + var transientClaims = entries + .Where(x => x[0] != ClaimTypes.Role) + .Select(x => new KeyValuePair(x[0], x[1])); + + return (transientRoles, transientClaims); + } +} diff --git a/Nano.Data.Abstractions/Identity/Authentication/IAuthIdentityRepository.cs b/Nano.Data.Abstractions/Identity/Authentication/IAuthIdentityRepository.cs index ed8478a1..ef78f27f 100644 --- a/Nano.Data.Abstractions/Identity/Authentication/IAuthIdentityRepository.cs +++ b/Nano.Data.Abstractions/Identity/Authentication/IAuthIdentityRepository.cs @@ -66,12 +66,16 @@ Task LogInExternalAsync(string providerName, LogInExternal /// Refreshes an existing access token using a valid refresh token, generating a new JWT and refresh token. + /// Non-persisted "transient" roles/claims are never accepted from the caller here - they are recovered + /// from the manifest embedded in at login, so a refresh can never grant more + /// than the original login already did. /// - /// The refresh login request containing the original token, refresh token, roles, and claims. + /// The expired or soon-to-expire access token, read from the caller's Authorization header. + /// The refresh token used to issue a new access token. /// A to cancel the operation. /// A task that returns a new with updated expiration. /// Thrown if the refresh token is missing, invalid, expired, or does not match the stored token. - Task LogInRefreshAsync(LogInRefresh logInRefresh, CancellationToken cancellationToken = default); + Task LogInRefreshAsync(string token, string refreshToken, CancellationToken cancellationToken = default); /// /// Logs out the current user, removing any server-side authentication state. diff --git a/Nano.Data.Abstractions/Identity/Authentication/Models/LogInRefresh.cs b/Nano.Data.Abstractions/Identity/Authentication/Models/LogInRefresh.cs index e0e49669..1885eb01 100644 --- a/Nano.Data.Abstractions/Identity/Authentication/Models/LogInRefresh.cs +++ b/Nano.Data.Abstractions/Identity/Authentication/Models/LogInRefresh.cs @@ -1,33 +1,18 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Nano.Data.Abstractions.Identity.Authentication.Models; /// -/// Represents a request to refresh an access token. +/// Wire-level request body for refreshing an access token - used for HTTP model binding and the api +/// client only, not passed to directly. +/// The expired or soon-to-expire access token itself is not part of this model - it is read from the +/// request's own Authorization header, not the body, so there is exactly one source of truth for which +/// session is being refreshed. /// public class LogInRefresh { - /// - /// The expired or soon-to-expire access token. - /// - [Required] - public virtual required string Token { get; set; } - /// /// The refresh token used to issue a new access token. /// [Required] public virtual required string RefreshToken { get; set; } - - /// - /// Non-persisted roles added to the issued JWT during refresh. - /// - [Required] - public virtual IEnumerable TransientRoles { get; set; } = []; - - /// - /// Non-persisted claims added to the issued JWT during refresh. - /// - [Required] - public virtual IEnumerable> TransientClaims { get; set; } = []; } \ No newline at end of file diff --git a/Nano.Data.Abstractions/Identity/Consts/ClaimTypesExtended.cs b/Nano.Data.Abstractions/Identity/Consts/ClaimTypesExtended.cs index 37bdbae9..e3f33862 100644 --- a/Nano.Data.Abstractions/Identity/Consts/ClaimTypesExtended.cs +++ b/Nano.Data.Abstractions/Identity/Consts/ClaimTypesExtended.cs @@ -25,6 +25,14 @@ public static class ClaimTypesExtended /// public static string ExternalProviderRefreshToken => "external_provider_refresh_token"; + /// + /// Claim type for the manifest recording which roles/claims on this token were asserted as + /// non-persisted "transient" ones at login, so a refresh can recover and carry forward exactly + /// that set instead of trusting the refresh caller to resupply it. The value is an opaque, + /// internally-encoded blob - see TransientClaimsManifest - not meant to be read directly. + /// + public static string TransientClaimsManifest => "transient_claims_manifest"; + /// /// Claim type for the API key identifier. /// diff --git a/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs b/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs index 1d4c81f5..aed9e86a 100644 --- a/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs +++ b/Nano.Data.Abstractions/Identity/Extensions/HttpContextExtensions.cs @@ -98,25 +98,8 @@ public static class HttpContextExtensions { ArgumentNullException.ThrowIfNull(httpContext); - const string PREFIX = "Baerer "; - - var authorizationHeader = httpContext.Request.Headers["Authorization"].ToString(); - - if (string.IsNullOrEmpty(authorizationHeader)) - { - return null; - } - - if (authorizationHeader.Length <= PREFIX.Length) - { - return null; - } - - var value = authorizationHeader[PREFIX.Length..]; - - return value == string.Empty - ? null - : value; + return httpContext.Request.Headers["Authorization"].ToString() + .GetJwtToken(); } /// diff --git a/Nano.Data.Abstractions/Identity/Extensions/StringExtensions.cs b/Nano.Data.Abstractions/Identity/Extensions/StringExtensions.cs index 9988e9cb..354f5b9d 100644 --- a/Nano.Data.Abstractions/Identity/Extensions/StringExtensions.cs +++ b/Nano.Data.Abstractions/Identity/Extensions/StringExtensions.cs @@ -45,6 +45,33 @@ public static TIdentity ConvertToIdentity(this string value) throw new InvalidOperationException($"Unsupported identity type: {target.FullName}"); } + /// + /// Extracts the JWT token from a raw Authorization header value (e.g. bound via [FromHeader] + /// on a minimal API endpoint, where reading the full HttpContext isn't needed). + /// + /// The raw Authorization header value. + /// The JWT token string, or null if not present or invalid. + public static string? GetJwtToken(this string? authorizationHeader) + { + const string PREFIX = "Bearer "; + + if (string.IsNullOrEmpty(authorizationHeader)) + { + return null; + } + + if (authorizationHeader.Length <= PREFIX.Length) + { + return null; + } + + var value = authorizationHeader[PREFIX.Length..]; + + return value == string.Empty + ? null + : value; + } + internal static ApiVersion ToApiVersion(this string version) { ArgumentNullException.ThrowIfNull(version); diff --git a/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs b/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs index d786268d..6d7b5fe3 100644 --- a/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs +++ b/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Identity; using Nano.Data.Abstractions.Exceptions; using Nano.Data.Abstractions.Extensions; +using Nano.Data.Abstractions.Identity.Authentication.Helpers; namespace Nano.Data.Identity.Authentication; @@ -57,6 +58,11 @@ public virtual async Task LogInAsync(LogIn logIn, CancellationToken var claims = await this.identityRepository .GetAllUserClaims(identityUser, logIn.TransientRoles, logIn.TransientClaims, cancellationToken); + var transientClaimsManifest = TransientClaimsManifest.Build(logIn.TransientRoles, logIn.TransientClaims); + + claims + .Add(transientClaimsManifest); + var accessToken = this.authJwtRepository .GenerateJwtToken(new GenerateJwtToken { @@ -92,6 +98,11 @@ public virtual async Task LogInExternalAsync(LogInExternal logInExt var claims = await this.identityRepository .GetAllUserClaims(identityUser, logInExternal.TransientRoles, logInExternal.TransientClaims, cancellationToken); + var transientClaimsManifest = TransientClaimsManifest.Build(logInExternal.TransientRoles, logInExternal.TransientClaims); + + claims + .Add(transientClaimsManifest); + var accessToken = this.authJwtRepository .GenerateJwtToken(new GenerateJwtToken { @@ -139,20 +150,21 @@ public virtual async Task LogInExternalAsync(string provider } /// - public virtual async Task LogInRefreshAsync(LogInRefresh logInRefresh, CancellationToken cancellationToken = default) + public virtual async Task LogInRefreshAsync(string jwtToken, string refreshToken, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(logInRefresh); + ArgumentNullException.ThrowIfNull(jwtToken); + ArgumentNullException.ThrowIfNull(refreshToken); var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); var userId = jwtSecurityTokenHandler - .GetJwtUserId(logInRefresh.Token); + .GetJwtUserId(jwtToken); var identityUser = await this.identityRepository .GetIdentityUserAsync(userId, cancellationToken); var appId = jwtSecurityTokenHandler - .GetJwtAppId(logInRefresh.Token) ?? IdentityDefaults.DEFAULT_APP_ID; + .GetJwtAppId(jwtToken) ?? IdentityDefaults.DEFAULT_APP_ID; var identityRefreshToken = await this.identityRepository .GetRefreshToken(userId, appId, cancellationToken); @@ -162,7 +174,7 @@ public virtual async Task LogInRefreshAsync(LogInRefresh logInRefre throw new UnauthorizedException($"The refresh token of user: {identityUser.UserName} could not be found."); } - if (identityRefreshToken.Value != logInRefresh.RefreshToken) + if (identityRefreshToken.Value != refreshToken) { throw new UnauthorizedException($"The refresh token of user: {identityUser.UserName} is invalid."); } @@ -173,17 +185,28 @@ public virtual async Task LogInRefreshAsync(LogInRefresh logInRefre } this.authJwtRepository - .ValidateTokenForRefresh(logInRefresh.Token); + .ValidateTokenForRefresh(jwtToken); + + var jwtSecurityToken = jwtSecurityTokenHandler + .ReadJwtToken(jwtToken); + + var (transientRoles, transientClaims) = TransientClaimsManifest + .Parse(jwtSecurityToken.Claims); var claims = await this.identityRepository - .GetAllUserClaims(identityUser, logInRefresh.TransientRoles, logInRefresh.TransientClaims, cancellationToken); + .GetAllUserClaims(identityUser, transientRoles, transientClaims, cancellationToken); + + var transientClaimsManifest = TransientClaimsManifest.Build(transientRoles, transientClaims); + + claims + .Add(transientClaimsManifest); - var externalProviderName = claims + var externalProviderName = jwtSecurityToken.Claims .Where(x => x.Type == ClaimTypesExtended.ExternalProviderName) .Select(x => x.Value) .FirstOrDefault(); - var externalProviderRefreshToken = claims + var externalProviderRefreshToken = jwtSecurityToken.Claims .Where(x => x.Type == ClaimTypesExtended.ExternalProviderRefreshToken) .Select(x => x.Value) .FirstOrDefault(); From 6a018ab4936f858661babf94aa024ebf8eeabd64 Mon Sep 17 00:00:00 2001 From: vivet Date: Fri, 18 Sep 2026 14:45:53 +0200 Subject: [PATCH 10/10] Updated --- Directory.Build.props | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e0a315c0..dd86c396 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -24,13 +24,12 @@ Reusable libraries for building .NET microservice applications This package is part of the Nano Library, a set of reusable .NET libraries for building microservice applications. Nano addresses common non-business concerns such as logging, persistence, messaging, validation, and documentation, while remaining fully configurable and extensible, so applications can stay focused on business logic. See https://github.com/Nano-Core/Nano.Library for details. - - Added Claude skills - - Added Copilot instructions and prompts - - Added IAuthTransientRepository.LogInExternalRefreshAsync(...) to refresh login for transient external login - - Moved Query Criterias from Nano.App.Api to Nano.App - - Updated all NuGet's - - Updated documentation in README.md. - - Removed experimental from Nano.App.Web + - Added httpContextExntesion.GetJwtClaimValues() + - Added prompts and skill for adding Microsoft authentication + - Updated Facebook authentication implementation + - Updated Microsoft authentication implementation + - Removed script for copying skills + - Fixed login-refresh claim trust and added transient external-login refresh support. git master