diff --git a/.claude/skills/nano-add-authentication-jwt/SKILL.md b/.claude/skills/nano-add-authentication-jwt/SKILL.md index abb9001e..417ed0cb 100644 --- a/.claude/skills/nano-add-authentication-jwt/SKILL.md +++ b/.claude/skills/nano-add-authentication-jwt/SKILL.md @@ -207,12 +207,26 @@ 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. + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. 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. +- **Facebook logins can never be refreshed — don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable — see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only — setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them — for Google specifically, + refresh support also needs the frontend's authorize request to include `access_type=offline`/ + `prompt=consent`, which has nothing to do with this `Scopes` entry either. **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 index eb01d820..df9008de 100644 --- a/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -51,9 +51,9 @@ convention for those the way this skill does for Microsoft. | 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 + Default to `AzureADMyOrg` if the user has no specific need — it's the least-privilege choice for + internal, single-tenant auth. 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. @@ -67,20 +67,35 @@ Base `appsettings.json`, nested under the existing `Jwt` block: "TenantId": null, "ClientId": null, "ClientSecret": null, - "Scopes": [ "openid", "profile", "email" ] + "Scopes": [ "openid", "profile", "email", "offline_access" ] } } ``` -`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 → 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. +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate — Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match — include `offline_access` +there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so +this app's own config alone can't retroactively grant it. + +**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key +pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked +Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to +whatever Entra ID app registration each individual developer creates for themselves — there's +nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure +Portal → Microsoft 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) adds their own real values to +their own local `appsettings.Development.json` at that point, overriding just the fields they have +values for — not something this skill pre-creates. No Graph API permission is needed beyond the +default — `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and +whether a `refresh_token` is issued alongside it, 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 @@ -237,20 +252,6 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor 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. - -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 @@ -264,3 +265,27 @@ app whose audience was actually asked about in step 5. - 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` — don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in — the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config — it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/.claude/skills/nano-add-custom-endpoint/SKILL.md b/.claude/skills/nano-add-custom-endpoint/SKILL.md index f87cc760..531bfe11 100644 --- a/.claude/skills/nano-add-custom-endpoint/SKILL.md +++ b/.claude/skills/nano-add-custom-endpoint/SKILL.md @@ -353,10 +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 (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. +Check this deliberately rather than assuming inference works — e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **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(...)]` diff --git a/.claude/skills/nano-add-data-provider/SKILL.md b/.claude/skills/nano-add-data-provider/SKILL.md index 8779a448..6bb5bd37 100644 --- a/.claude/skills/nano-add-data-provider/SKILL.md +++ b/.claude/skills/nano-add-data-provider/SKILL.md @@ -271,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ 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. + ⚠ Add only the one migration step matching the chosen provider, unconditionally — no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives — unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. 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 diff --git a/.claude/skills/nano-add-entity/SKILL.md b/.claude/skills/nano-add-entity/SKILL.md index d7538302..994e8e3f 100644 --- a/.claude/skills/nano-add-entity/SKILL.md +++ b/.claude/skills/nano-add-entity/SKILL.md @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria 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) + (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) — check the project's other query criteria classes for the operations actually available, don't guess. diff --git a/.claude/skills/nano-add-event-handler/SKILL.md b/.claude/skills/nano-add-event-handler/SKILL.md new file mode 100644 index 00000000..748cc65c --- /dev/null +++ b/.claude/skills/nano-add-event-handler/SKILL.md @@ -0,0 +1,110 @@ +--- +name: nano-add-event-handler +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application — a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first — it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op — the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** — the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** — some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it — don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared — the handler itself (below) is identical either way. + +**Local** — the class lives directly in `{App}/Eventing/` (conventional location, not enforced — +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** — the class moves out to its own sibling project, `{App}.Events` — same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` — mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step — this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs — plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared — +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed — every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly — +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** — an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available — wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/.claude/skills/nano-add-storage-provider/SKILL.md b/.claude/skills/nano-add-storage-provider/SKILL.md index 01ec11ec..34089aa4 100644 --- a/.claude/skills/nano-add-storage-provider/SKILL.md +++ b/.claude/skills/nano-add-storage-provider/SKILL.md @@ -104,9 +104,14 @@ provider) — there's nothing to run, just a directory. isolated from the others — a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below — its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required — see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** — per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required — see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") — include `tmp` for **both** providers, it's provider-agnostic: @@ -160,7 +165,8 @@ provider) — there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed — referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply — `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/.claude/skills/nano-remove-authentication-microsoft/SKILL.md b/.claude/skills/nano-remove-authentication-microsoft/SKILL.md new file mode 100644 index 00000000..8351e9fa --- /dev/null +++ b/.claude/skills/nano-remove-authentication-microsoft/SKILL.md @@ -0,0 +1,73 @@ +--- +name: nano-remove-authentication-microsoft +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application — the counterpart to `nano-add-authentication-microsoft`. Read +that skill first — this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead — its own removal already takes `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars — if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does — each provider's config/Kubernetes/CI is independent — but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove — flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to — `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production — only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources — removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered — the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected — sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above — the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails — the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/.claude/skills/nano-remove-data-provider/SKILL.md b/.claude/skills/nano-remove-data-provider/SKILL.md index b190b1e9..1e5c0814 100644 --- a/.claude/skills/nano-remove-data-provider/SKILL.md +++ b/.claude/skills/nano-remove-data-provider/SKILL.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 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` + present — `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). 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 + `nano-add-data-provider` doesn't add 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` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists — check before removing). diff --git a/.claude/skills/nano-remove-event-handler/SKILL.md b/.claude/skills/nano-remove-event-handler/SKILL.md new file mode 100644 index 00000000..50af285b --- /dev/null +++ b/.claude/skills/nano-remove-event-handler/SKILL.md @@ -0,0 +1,42 @@ +--- +name: nano-remove-event-handler +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application — the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one — check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project — see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event — this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up — discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project — delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first — this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/.github/prompts/nano-add-authentication-jwt.prompt.md b/.github/prompts/nano-add-authentication-jwt.prompt.md index dacb9124..7ae3707e 100644 --- a/.github/prompts/nano-add-authentication-jwt.prompt.md +++ b/.github/prompts/nano-add-authentication-jwt.prompt.md @@ -3,7 +3,6 @@ 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 @@ -37,17 +36,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 @@ -56,14 +55,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 @@ -80,10 +79,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. @@ -208,12 +207,26 @@ 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. + it has an established, self-rotating Kubernetes-secret/GitHub-Actions convention. 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. +- **Facebook logins can never be refreshed - don't offer an `offline_access`-style option for it.** + `AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws, regardless of + config, yet `.../transient/refresh` is still auto-mapped for every registered provider and will + always 401 for Facebook. If the user asks for refresh support on a Facebook login, say plainly + that it isn't possible with the built-in provider rather than looking for a config option that + doesn't exist. Google and Microsoft, by contrast, are both refreshable - see AGENTS.md's + `#### Authentication` table. +- **`Facebook.Scopes`/`Google.Scopes` are frontend-only - setting them here does nothing server-side.** + Neither repository reads `options.Scopes` at all; scope negotiation happens in the client-side SDK + (Facebook) or the frontend's own authorize-URL redirect (Google) before Nano ever sees the + request. Still add them to config for documentation purposes if the user gives specific scopes, + but don't imply this app's config is what actually requests them - for Google specifically, + refresh support also needs the frontend's authorize request to include `access_type=offline`/ + `prompt=consent`, which has nothing to do with this `Scopes` entry either. **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/.github/prompts/nano-add-authentication-microsoft.prompt.md b/.github/prompts/nano-add-authentication-microsoft.prompt.md index 75b4db10..92a12e93 100644 --- a/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -51,9 +51,9 @@ convention for those the way this skill does for Microsoft. | 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 + Default to `AzureADMyOrg` if the user has no specific need - it's the least-privilege choice for + internal, single-tenant auth. 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. @@ -67,20 +67,35 @@ Base `appsettings.json`, nested under the existing `Jwt` block: "TenantId": null, "ClientId": null, "ClientSecret": null, - "Scopes": [ "openid", "profile", "email" ] + "Scopes": [ "openid", "profile", "email", "offline_access" ] } } ``` -`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 → 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. +`offline_access` is included by default since most apps want refresh support, and leaving it in +place is safe even for logins that don't use it: `LogInExternal`/`LogInExternal`'s +`IsRefreshable` flag is the real, per-login-call gate - Nano discards the external refresh token +server-side whenever a specific login request sets `IsRefreshable: false`, regardless of what +`Scopes` requested. Remove `offline_access` from `Scopes` only if this app should never support +refresh at all. + +The client-side authorize request's own `scope` parameter must match - include `offline_access` +there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so +this app's own config alone can't retroactively grant it. + +**`ExternalLogins.Microsoft` belongs in the base file only.** Unlike the shared JWT Development key +pair (a throwaway value every developer can share, so it's worth hardcoding into the tracked +Development file), a Microsoft app registration's `TenantId`/`ClientId`/`ClientSecret` are tied to +whatever Entra ID app registration each individual developer creates for themselves - there's +nothing shared to pre-seed. A developer who's created their own Entra ID app registration (Azure +Portal → Microsoft 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) adds their own real values to +their own local `appsettings.Development.json` at that point, overriding just the fields they have +values for - not something this skill pre-creates. No Graph API permission is needed beyond the +default - `openid`/`profile`/`email`/`offline_access` only affect what lands in the `id_token` and +whether a `refresh_token` is issued alongside it, 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 @@ -237,20 +252,6 @@ Apply it in the `Kubernetes Deploy` step alongside `auth-jwt-secret.yaml`, befor 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. - -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 @@ -264,3 +265,27 @@ app whose audience was actually asked about in step 5. - 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. +- Restate that `offline_access` was included in `Scopes` by default, and that the client-side + authorize request's own `scope` parameter must include it too for Microsoft to actually issue a + `refresh_token` - don't let confirming the config change alone read as the whole fix. +- **Always include the frontend half in your reply, even though this skill only touches the + backend.** The config change alone isn't enough to sign anyone in - the frontend has to redirect + the user through Microsoft's own sign-in first. Per AGENTS.md's `#### Authentication` section + (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the + authorize URL with this app's actual `TenantId`/`ClientId`/`RedirectUri` filled in (not left as + placeholders, once known): + ``` + https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + ``` + plus a short explanation that `code_challenge` isn't something to fill in from this app's own + config - it's a PKCE value the frontend itself must generate a `code_verifier` for, hash + (SHA-256, base64url-encoded) into `code_challenge` for this URL, and then send the raw + `code_verifier` back to the login endpoint alongside the `code` Microsoft returns. diff --git a/.github/prompts/nano-add-custom-endpoint.prompt.md b/.github/prompts/nano-add-custom-endpoint.prompt.md index b6939da6..65048c08 100644 --- a/.github/prompts/nano-add-custom-endpoint.prompt.md +++ b/.github/prompts/nano-add-custom-endpoint.prompt.md @@ -353,10 +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 (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. +Check this deliberately rather than assuming inference works - e.g. a request whose `TResponse` +is `MyFile` but whose action actually lives on `MyEntitiesController` (a file attached to an +entity, not a controller of its own) needs `this.Controller = "MyEntities";` set explicitly, the +same shape as the example above. **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(...)]` diff --git a/.github/prompts/nano-add-data-provider.prompt.md b/.github/prompts/nano-add-data-provider.prompt.md index 767788bc..fecad9a6 100644 --- a/.github/prompts/nano-add-data-provider.prompt.md +++ b/.github/prompts/nano-add-data-provider.prompt.md @@ -271,15 +271,12 @@ Provisioning that server is out of this skill's scope. AZURE_GROUP_DATABASE: ${{ vars.AZURE_RESOURCE_GROUP_DATABASE }} DOTNET_EF_TOOLS_VERSION: "10.0" ``` - ⚠ 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. + ⚠ Add only the one migration step matching the chosen provider, unconditionally - no `SQL_TYPE` + variable or `if:` guard needed. Don't add the other two providers' steps as dormant + alternatives - unreachable steps (and the `AZURE_GROUP_LOGS` env var the SQL Server one alone + needs) are clutter to maintain, not documentation. 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 diff --git a/.github/prompts/nano-add-entity.prompt.md b/.github/prompts/nano-add-entity.prompt.md index 1a09afcc..75ed9535 100644 --- a/.github/prompts/nano-add-entity.prompt.md +++ b/.github/prompts/nano-add-entity.prompt.md @@ -256,7 +256,7 @@ public class QueryCriteria : BaseQueryCriteria 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) + (`StartsWith`/`Contains` for strings, `Equal`/`GreaterThan`/etc. for numerics and dates) - check the project's other query criteria classes for the operations actually available, don't guess. diff --git a/.github/prompts/nano-add-event-handler.prompt.md b/.github/prompts/nano-add-event-handler.prompt.md new file mode 100644 index 00000000..c5dd56e5 --- /dev/null +++ b/.github/prompts/nano-add-event-handler.prompt.md @@ -0,0 +1,110 @@ +--- +mode: agent +description: Add an event handler to a Nano application - a class deriving BaseEventHandler that subscribes to messages published via IEventing.PublishAsync. Use when the user asks to add an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing to a Nano API, Web, or Console application. Not for Entity Events (automatic per-entity-save publishing, which needs no handler at all) - see AGENTS.md's Entity Events section for that. +--- + +# Nano add event handler + +Adds an event handler to an existing Nano application - a class deriving `BaseEventHandler` that +consumes messages published via `IEventing.PublishAsync`. Read AGENTS.md's `### Publish and Subscribe` +section first - it documents the mechanism in full; this skill is just the file shape. + +## Before making any change, determine + +1. **Is an eventing provider configured?** Check `Program.cs` for `AddNanoEventing()` (see + [nano-add-eventing-provider](nano-add-eventing-provider) if not). Per AGENTS.md's ⚠, a handler added + without a provider configured is a silent no-op - the registration task that subscribes handlers never + runs, so nothing happens at startup and nothing errors either. +2. **Local or shared event?** If not stated, ask. This decides where the message contract (`TEvent`) + class lives: + - **Local** - the event is only ever published and consumed within this same solution. The contract + class lives directly in this app project. This is the default when in doubt. + - **Shared** - some other Nano application, in a different solution, will need to publish or subscribe + to this same event later. The contract class lives in its own publishable sibling project instead, + so it can be referenced without pulling in this app's other code. +3. **Does the event contract already exist?** Check for an existing class named after the event (local: + inside this app; shared: in a `{App}.Events` sibling project, if one already exists). If it exists, + reuse it - don't create a second contract for the same message. +4. **Does this handler need a specific routing key or prefetch override?** Ask only if the same event type + has (or will have) more than one handler that needs to be selectively targeted, or if this handler's + processing is heavier than the app-wide default prefetch count. Most handlers need neither. + +## Event contract + +Only this part differs between local and shared - the handler itself (below) is identical either way. + +**Local** - the class lives directly in `{App}/Eventing/` (conventional location, not enforced - +discovered by type): + +```csharp +// {App}/Eventing/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +**Shared** - the class moves out to its own sibling project, `{App}.Events` - same idea as the +`{App}.Models` project AGENTS.md's Solution Structure table already documents, just for event contracts +instead of entities/API clients: + +1. Create the `{App}.Events` project (new, if it doesn't exist yet) as a sibling of `{App}` and + `{App}.Models` - mirror `{App}.Models.csproj`'s packaging metadata (versioning, `GeneratePackageOnBuild`, + authors, description, etc.) so it can be packed and published the same way. +2. Add it to this solution's `.sln`. +3. Add a `ProjectReference` from `{App}` to `{App}.Events`, so this app can both publish the event and + host the handler for it. +4. Add a "Publish NuGet" step for it in `.github/workflows/build-and-deploy.yml`, mirroring the existing + `.Models` pack/push step - this is what lets a different solution consume it later; this skill does not + touch any other solution itself. + +```csharp +// {App}.Events/MyEvent.cs - plain class, no base type required +public class MyEvent +{ + public string Text { get; set; } = null!; +} +``` + +## Handler class + +Always lives in `{App}/Eventing/MyEventHandler.cs`, whether the event it handles is local or shared - +only which project `MyEvent` comes from changes, never where the handler itself lives: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) + { + // handle @event; isRedelivered is true if the broker is retrying a previously-failed delivery + } +} +``` + +No registration needed - every non-generic `BaseEventHandler` in the entry assembly is discovered +and subscribed automatically at startup, once an eventing provider is configured. + +If step 4 (in "Before making any change") identified a real routing/prefetch need, declare these two +static properties on the handler class itself, matching `IEventingHandler`'s member names exactly - +`RegisterEventingHandlersTask` looks them up by name via reflection on your concrete class, so declaring +them is enough; there's no override or `new` keyword involved: + +```csharp +public class MyEventHandler : BaseEventHandler +{ + public static string RoutingKey => "my-routing-key"; + public static ushort OverridePrefetchCount => 10; + + public override async Task CallbackAsync(MyEvent @event, bool isRedelivered, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +⚠ The handler class itself must be **non-generic** - an open generic handler is silently skipped during +discovery. + +## After making the change + +- Show the user the files added (event contract, handler, and for shared events, the new project + sln + + CI changes). +- For a shared event, remind the user that publishing the NuGet only makes it available - wiring it into + another solution's app is that app's own separate change, not something this skill does. diff --git a/.github/prompts/nano-add-storage-provider.prompt.md b/.github/prompts/nano-add-storage-provider.prompt.md index 279eda77..15048379 100644 --- a/.github/prompts/nano-add-storage-provider.prompt.md +++ b/.github/prompts/nano-add-storage-provider.prompt.md @@ -104,9 +104,14 @@ provider) - there's nothing to run, just a directory. isolated from the others - a file written via one replica isn't visible from another. If the app instead needs one *shared* volume across replicas, that's what `Azure` storage is for, below - its file-share CSI mount supports concurrent multi-pod access.) -- **`.kubernetes/deployment.yaml`**: change `kind: Deployment` → `kind: StatefulSet`, and add - `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` field, - required - see the headless service below). Mount the volume, plus the standard `tmp` +- **Replace `.kubernetes/deployment.yaml` with a new `.kubernetes/stateful-set.yaml`** - per + AGENTS.md's Solution Structure table, the two are mutually exclusive, and `stateful-set.yaml` + replaces `deployment.yaml` entirely rather than the two coexisting. Delete `deployment.yaml`, + create `stateful-set.yaml` with the same content plus `kind: StatefulSet` (not `Deployment`) and + `serviceName: %SERVICE_NAME%-stateful-headless` alongside `replicas`/`selector` (a `StatefulSet` + field, required - see the headless service below); update the `.sln`'s `.kubernetes` + `SolutionItems` block to reference the new filename instead of the old one. Mount the volume, + plus the standard `tmp` `emptyDir` volume that backs `IPathProvider`'s temporary directory (AGENTS.md: registering a provider "also registers `IPathProvider` ... exposing the storage root and a temporary (`tmp`) directory") - include `tmp` for **both** providers, it's provider-agnostic: @@ -160,7 +165,8 @@ provider) - there's nothing to run, just a directory. `Gi`) and `STORAGE_SHARE_NAME` env vars, and apply `storage-storageclass.yaml` (still needed - referenced by name from `volumeClaimTemplates`) and `service-headless.yaml` (same `Get-Content | ExpandEnvironmentVariables | kubectl apply` - pattern as every other manifest) in `Kubernetes Deploy`, before `deployment.yaml`. There's no + pattern as every other manifest) in `Kubernetes Deploy`, before `stateful-set.yaml` (which + replaces the `deployment.yaml` apply line, per the rename above). There's no separate PVC file to apply - `volumeClaimTemplates` creates one per pod automatically as the `StatefulSet` itself is applied. Also add `.kubernetes\storage-storageclass.yaml = .kubernetes\storage-storageclass.yaml` and `.kubernetes\service-headless.yaml = diff --git a/.github/prompts/nano-remove-authentication-microsoft.prompt.md b/.github/prompts/nano-remove-authentication-microsoft.prompt.md new file mode 100644 index 00000000..81675941 --- /dev/null +++ b/.github/prompts/nano-remove-authentication-microsoft.prompt.md @@ -0,0 +1,73 @@ +--- +mode: agent +description: Remove Nano's built-in Microsoft external login provider (App:Authentication:Jwt:ExternalLogins:Microsoft) from a Nano.Library-based application - unregisters the config and removes the Staging/Production app-registration/CI/Kubernetes wiring, without touching JWT authentication itself. Use when the user asks to remove "Sign in with Microsoft", Entra ID, or Azure AD external login from a Nano API or Web application while keeping regular JWT login - not for removing JWT authentication entirely, that's nano-remove-authentication-jwt (whose own removal already covers any ExternalLogins provider along with it). +--- + +# Nano remove Microsoft authentication + +Removes just the `Microsoft` external login provider (`Jwt.ExternalLogins.Microsoft`) from an +existing Nano API/Web application - the counterpart to `nano-add-authentication-microsoft`. Read +that skill first - this one undoes exactly what it adds. `Jwt` itself, the `AuthController`, and +any other configured provider (`Facebook`/`Google`) are left untouched. + +If the user actually wants JWT authentication removed entirely, stop and point them at +`nano-remove-authentication-jwt` instead - its own removal already takes `ExternalLogins` (whichever +providers are configured) down with it; running this skill first would just be redundant. + +## Before making any change, determine + +1. **Is Microsoft external login currently configured?** Check the base `appsettings.json` for + `Jwt.ExternalLogins.Microsoft`. If not present, say so and stop. +2. **Was this wired up for Staging/Production, or Development only?** Check + `.kubernetes/auth-microsoft-secret.yaml`, the `Setup App Registration` workflow step, and the + `AUTH_MICROSOFT_*` workflow env vars - if none exist, this was Development-only and the + Kubernetes/CI section below doesn't apply. +3. **Are other external login providers (`Facebook`/`Google`) still configured?** Doesn't change + what this skill does - each provider's config/Kubernetes/CI is independent - but worth confirming + so the user isn't surprised those keep working unchanged. +4. **Any custom external-login repository or frontend code referencing Microsoft specifically?** + This skill only removes the built-in provider's config and infrastructure; a hand-written MSAL.js + sign-in flow on the frontend, or a custom class deriving `BaseAuthExternalRepository` for + Microsoft, isn't something this skill can find or remove - flag that the frontend sign-in button + and redirect flow need removing separately. + +## appsettings.json + +Remove the `Microsoft` object from `Jwt.ExternalLogins` in the base `appsettings.json` (per +`nano-add-authentication-microsoft`, this is the only file it's ever added to - `appsettings.Development.json` +may also have a developer's own local override values under the same path; remove those too if +present). If `ExternalLogins` is now empty, remove the empty `ExternalLogins` object as well rather +than leaving a dangling `{}`. + +## Staging/Production - only if step 2 found it wired up + +- Remove the `Setup App Registration` workflow step. +- Remove the `AUTH_MICROSOFT_REDIRECT_URI`/`AUTH_MICROSOFT_SIGN_IN_AUDIENCE` workflow-level env vars. +- Remove the `auth-microsoft-secret.yaml` apply line from the `Kubernetes Deploy` step. +- Delete `.kubernetes/auth-microsoft-secret.yaml`, and remove its + `.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml` line from + `{name}.sln`'s `.kubernetes` `SolutionItems` block. +- Remove the `App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId`/`ClientId`/ + `ClientSecret` entries from `.kubernetes/deployment.yaml`'s container `env`. + +⚠ This does **not** delete the underlying live resources - removing the workflow/manifest lines +only stops maintaining them going forward, the same class of gap as +`nano-remove-authentication-jwt`'s equivalent note: +- The Entra ID app registration itself (`{service-name}-app` in Azure) stays registered - the + workflow step that creates/updates it just stops running. Delete it directly in the Azure Portal + (or `az ad app delete`) if it's no longer needed for anything else. +- The `auth-microsoft-secret` Kubernetes `Secret` already sitting in the cluster from prior deploys. +Say both explicitly rather than letting the user assume "removed the file/workflow lines" means +"removed the actual app registration." + +## After making the change + +- Show the user every file touched/deleted. +- Confirm JWT authentication (and any other configured provider) is unaffected - sign-in with a + password/other provider keeps working exactly as before, only Microsoft sign-in disappears. +- If step 2 found Staging/Production wiring, restate the ⚠ above - the live Entra ID app + registration and Kubernetes secret still exist; only this app's manifest/CI references were + removed. +- If step 4 found frontend sign-in code, remind the user that removing the backend config alone + leaves a "Sign in with Microsoft" button that now fails - the frontend piece is outside this + skill's scope and needs removing separately. diff --git a/.github/prompts/nano-remove-data-provider.prompt.md b/.github/prompts/nano-remove-data-provider.prompt.md index 777c6991..91df5c29 100644 --- a/.github/prompts/nano-remove-data-provider.prompt.md +++ b/.github/prompts/nano-remove-data-provider.prompt.md @@ -112,12 +112,12 @@ If the provider was `SqLite`, additionally: Skip entirely for `SqLite`/`InMemory` (covered above / never applicable). 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` + present - `nano-add-data-provider` only ever adds the one step matching the chosen provider, no + dormant alternatives for the other two). 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 + `nano-add-data-provider` doesn't add 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` is only added for `SqlServer` in the first place, and is also used by an Availability Check step, if one exists - check before removing). diff --git a/.github/prompts/nano-remove-event-handler.prompt.md b/.github/prompts/nano-remove-event-handler.prompt.md new file mode 100644 index 00000000..edd79610 --- /dev/null +++ b/.github/prompts/nano-remove-event-handler.prompt.md @@ -0,0 +1,42 @@ +--- +mode: agent +description: Remove an event handler from a Nano application - deletes the BaseEventHandler-derived class. Use when the user asks to remove an event handler, subscriber, or consumer for Nano's Publish/Subscribe eventing from a Nano API, Web, or Console application. +--- + +# Nano remove event handler + +Removes an event handler from an existing Nano application - the counterpart to +`nano-add-event-handler`. + +## Before making any change, determine + +1. **Which handler?** Confirm the class name/file if the project has more than one - check + `{App}/Eventing/` (or search for `BaseEventHandler<` if not in the conventional location). +2. **Is the event's contract class local or shared?** Local (defined directly in this app) or shared (in + a `{App}.Events` sibling project - see `nano-add-event-handler`). This decides what else, if anything, + needs cleaning up beyond the handler itself. +3. **If shared: does this app still publish that event anywhere?** Removing the handler doesn't remove + the event - this app (or the handler's removal notwithstanding) might still call `PublishAsync` for it + elsewhere. Check before touching the contract class or its project. +4. **If shared and nothing in this app still publishes or subscribes to it: is it the only event type left + in `{App}.Events`?** If so, the whole project is now dead weight in this solution. + +## Removing the handler + +Delete the handler class. No config, no registration, no other references to clean up - discovery is by +type, so removing the class is the entire change for the handler itself. + +## Removing the contract (only if steps 3–4 say so) + +- **Local event, no longer published:** delete the contract class alongside the handler. +- **Shared event, no longer published or subscribed to anywhere in this app, and it was the only event + type in `{App}.Events`:** offer to remove the whole `{App}.Events` project - delete it, remove it from + the `.sln`, remove the `ProjectReference` from `{App}`, and remove its "Publish NuGet" step from + `.github/workflows/build-and-deploy.yml`. Confirm with the user first - this is a published package, and + another solution may already depend on the last-published version even if nothing in *this* solution + does anymore. + +## After making the change + +- Show the user the file(s) removed. +- If the contract or the `{App}.Events` project was removed too, say so explicitly and why. diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 1037b7b3..f1a6e3be 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.13 + VERSION: 10.0.14 jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/.tests/Tests.Nano.Library/Tests.Nano.Library.csproj b/.tests/Tests.Nano.Library/Tests.Nano.Library.csproj index 21496ab5..d226165c 100644 --- a/.tests/Tests.Nano.Library/Tests.Nano.Library.csproj +++ b/.tests/Tests.Nano.Library/Tests.Nano.Library.csproj @@ -9,9 +9,9 @@ - - - + + + diff --git a/AGENTS.md b/AGENTS.md index 10e15470..6cb764e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ inside `{name}/`. | `{name}.Models/Data/` | ✓ | ✓ | ✗ | Entity models (conventional location). | | `{name}.Models/Criterias/` | ✓ | ✓ | ✗ | Query criteria classes (conventional location). | | `{name}.Models/Api/` | ✓ | ✓ | ✗ | API client + `Requests/` (conventional location, for apps exposing a typed client to consumers). | +| `{name}.Events/{name}.Events.csproj` | (✓) | (✓) | (✓) | Sibling project holding Publish/Subscribe event contract classes _(optional — only when this app has a **shared** event, one another application in a different solution needs to publish or subscribe to; see [Nano.Eventing § Publish and Subscribe](#publish-and-subscribe)). Publishable as its own NuGet, same as `{name}.Models`. A **local** event (used only within this solution) stays a plain class in `{name}/Eventing/` instead — no separate project needed._ | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project — empty by default, demonstrates where unit/integration tests belong. | | `.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. | @@ -81,7 +82,7 @@ line to that block, or it exists on disk but never shows up in the solution. **NuGet packages**: for a quick start, add `NanoCore` (all-inclusive; `Nano.All` is the identical, differently-named package underneath it — either one works the same way) to `{name}.Models` only — since `{name}` references `{name}.Models` via `ProjectReference`, every Nano package flows into the app project transitively, so no Nano -package reference is needed there directly. This is what Nano.Templates itself does. Once you know which providers +package reference is needed there directly. Once you know which providers you're actually using, switch to referencing only the specific packages you need — smaller dependency footprint, and it makes provider choices explicit in the `.csproj` rather than implicit via a meta-package: - `Nano.App` goes on `{name}.Models` — it's the only Nano package that project needs (entity/query-criteria base @@ -1563,14 +1564,83 @@ what the client sends: | Provider | Flow | Client sends | Credentials come from | | ----------- | ------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | | `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`). | +| `Google` | `AuthCodeFlow` | `Code`/`CodeVerifier`/`RedirectUri` — the server exchanges the authorization code for tokens itself (see `AuthExternalGoogleRepository`), same shape as Microsoft. `Scopes` must include `openid` (and should include `profile`/`email`) so the token response's `id_token` carries the `sub`/`name`/`email` claims Nano reads via `GoogleJsonWebSignature.ValidateAsync` — the `access_token` is not used for identity, only as the stored `ExternalToken`. | 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`. Include `offline_access` by default — most apps want refresh support, and requesting it doesn't force any single login to use it (see `IsRefreshable` below) — but the same scope must also be requested in the client-side authorize request, since consent for it is granted once, at that initial redirect. | A Microsoft Entra ID (Azure AD) app registration (`TenantId`/`ClientId`/`ClientSecret`). | + +⚠ **`Scopes` is inert on the backend for Facebook — it's a frontend-only concern there.** +`AuthExternalFacebookRepository` never reads `options.Scopes`; only `AppId`/`AppSecret` are used server-side. Scope +negotiation happens entirely in the client-side SDK when it obtains the token, before Nano ever sees the request — +Nano has no OAuth round-trip with Facebook at all, unlike Google's and Microsoft's `AuthCodeFlow`, both of which +genuinely exchange the authorization code server-side. Setting `Facebook.Scopes` in config only documents what the +frontend SDK should request; it has no runtime effect on this app. + +⚠ **Facebook logins can never be refreshed, by design — there's no `offline_access`-style opt-in.** +`AuthExternalFacebookRepository.AuthenticateRefreshAsync` unconditionally throws `UnauthorizedException`, regardless +of any config. Despite that, `RegisterTransientAuthEndpointsTask` still auto-maps +`POST /auth/login/external/facebook/transient/refresh` the same as every other registered provider — the route +exists, is visible in the API documentation, and will **always** return `401 Unauthorized` when called. This isn't a +bug to work around; don't build a client flow that assumes Facebook's login is refreshable, and don't confuse a +`401` from this specific route with an actual auth failure elsewhere. + +Google, unlike Microsoft, has no `Scopes` entry to opt into refresh — instead, the frontend's own authorize request +must include `access_type=offline` (and typically `prompt=consent`, since Google otherwise only issues a +`refresh_token` on a user's very first consent) as query parameters, not as a scope. Without both, `refresh_token` +is silently absent from Google's token response (not an error), same failure mode as Microsoft's missing +`offline_access`. + +`LogInExternal`/`LogInExternal`'s `IsRefreshable` flag is the real per-login-call gate for Google's and +Microsoft's refresh capability — Nano discards the external refresh token server-side whenever a login request sets +`IsRefreshable: false`, regardless of what the frontend requested. Setting it `true` against Facebook has no effect +either way, since there's never a refresh token to discard or keep. 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`. +client secret as a CI step. + +**Microsoft's `AuthCodeFlow` requires the frontend to redirect the user through Microsoft's own sign-in first, using +PKCE** — Nano's backend only ever sees the resulting authorization code, never the user's Microsoft credentials: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +`code_challenge` is not something to fill in from this app's own config — it's a PKCE value the frontend itself +generates: a random `code_verifier`, hashed (SHA-256) and base64url-encoded into `code_challenge` for this URL. +Microsoft redirects back to `redirect_uri` with `?code=...`, and the frontend then sends that `code` plus the +original, un-hashed `code_verifier` to Nano's login endpoint — exactly `AuthCodeFlow`'s `Code`/`CodeVerifier`/ +`RedirectUri` — which is what lets Microsoft's own token endpoint confirm whoever redeems the code is the same +client that started the flow. `state` is a separate, unguessable per-attempt value the frontend generates and +verifies on return, as CSRF protection for the redirect itself — unrelated to PKCE and not part of any Nano +request/response shape. + +**Google's `AuthCodeFlow` works the same way, through Google's own sign-in and PKCE** — same `Code`/`CodeVerifier`/ +`RedirectUri` shape, same `state` handling, just a different authorize endpoint and no `TenantId`: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} + &access_type=offline + &prompt=consent +``` + +`access_type=offline`/`prompt=consent` are only needed if this login should be refreshable — omit both if it +shouldn't be, same as omitting Microsoft's `offline_access`. **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 @@ -1937,9 +2007,9 @@ groupBC.Equal(nameof(MyEntity.C), c, LogicalType.Or); return new[] { groupA, groupBC }; // A AND (B OR C) ``` -This is why every real criteria class in Nano.Templates/Nano.Lessons keeps to a single `CriteriaExpression` with -only `And` (the default) — as soon as an `Or` is needed, the grouping above is what's actually required to get -correct results, not just adding another `.Or(...)` call in the same chain. +This is why most real-world criteria classes keep to a single `CriteriaExpression` with only `And` (the default) — +as soon as an `Or` is needed, the grouping above is what's actually required to get correct results, not just +adding another `.Or(...)` call in the same chain. ##### Nested and collection properties @@ -2265,7 +2335,7 @@ regardless of which provider you register. | Setting | Type | Default | Description | | --------------------------------- | ------ | ----------- | ------------------------------------------------------------------------------ | | `LogLevel` | enum | Information | Default minimum log level: `Debug`, `Information`, `Warning`, `Error`, `Fatal`. | -| `LogLevelOverrides[].Namespace` | string | null | Namespace to override (supports `*` prefix wildcard). | +| `LogLevelOverrides[].Namespace` | string | null | Namespace (or namespace prefix) to override — matched as a prefix, no wildcard character needed. | | `LogLevelOverrides[].LogLevel` | enum | Warning | Log level for that namespace. | ```json @@ -3225,7 +3295,9 @@ public class MyEventHandler : BaseEventHandler ``` Optionally scope a handler to a specific routing key, and/or override the globally-configured prefetch count, by -hiding the base interface's static members on your handler class: +declaring these two static properties directly on your handler class, matching `IEventingHandler`'s member names +exactly — the registration task looks them up by name via reflection on your concrete class, so declaring them is +enough; there's no override or `new` keyword involved: ```csharp public class MyEventHandler : BaseEventHandler diff --git a/Directory.Build.props b/Directory.Build.props index dd86c396..900ce04e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -24,12 +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 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. + - Updated NuGets + - Updated skills, prompts and AGENTS.md + - Fix transient auth endpoint detection to match generic BaseAuthController{T} + - Added discard of external provider refresh token when login is not refreshable + - Changed Google from implicit flow to auth-code flow + - Fixed incorrect cast to string for RegisterEventingHandlersTask overridePrefetchCount git master diff --git a/Nano.App.Api/Extensions/ServiceScopeExtensions.cs b/Nano.App.Api/Extensions/ServiceScopeExtensions.cs index 7e8b2f91..68f25716 100644 --- a/Nano.App.Api/Extensions/ServiceScopeExtensions.cs +++ b/Nano.App.Api/Extensions/ServiceScopeExtensions.cs @@ -21,7 +21,7 @@ internal static IServiceScope UseNanoEndpoints(this IServiceScope serviceScope, var hasAuthController = TypeCache .GetAllTypes() - .Any(x => x.IsTypeOf(typeof(BaseAuthController))); + .Any(x => x.IsTypeOf(typeof(BaseAuthController<>))); var hasIdentity = serviceScope .MapNanoIdentityEndpoints(builder, options, hasAuthController); diff --git a/Nano.App.Api/Mvc/Authentication/AuthExternalGoogleRepository.cs b/Nano.App.Api/Mvc/Authentication/AuthExternalGoogleRepository.cs index f2c7918c..6707ed53 100644 --- a/Nano.App.Api/Mvc/Authentication/AuthExternalGoogleRepository.cs +++ b/Nano.App.Api/Mvc/Authentication/AuthExternalGoogleRepository.cs @@ -4,36 +4,90 @@ using Nano.Data.Abstractions.Exceptions; 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.Collections.Generic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace Nano.App.Api.Mvc.Authentication; /// -public class AuthExternalGoogleRepository(GoogleOptions options) - : BaseAuthExternalRepository(BuiltInExternalLogInProviderNames.GOOGLE), IBuiltInAuthExternalRepository +public class AuthExternalGoogleRepository(GoogleOptions options, HttpClient httpClient) + : BaseAuthExternalRepository(BuiltInExternalLogInProviderNames.GOOGLE), IBuiltInAuthExternalRepository { private readonly GoogleOptions options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly HttpClient httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); /// - public override async Task AuthenticateAsync(ImplicitFlow flow, CancellationToken cancellationToken = default) + public override async Task AuthenticateAsync(AuthCodeFlow flow, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(flow); - if (options == null) - throw new ArgumentNullException(nameof(options)); + using var httpRequestMessage = new HttpRequestMessage(); + + httpRequestMessage.Method = HttpMethod.Post; + httpRequestMessage.RequestUri = new Uri("https://oauth2.googleapis.com/token"); + + 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 + }); + + var httpResponse = await this.httpClient + .SendAsync(httpRequestMessage, cancellationToken); + + var stringContent = await httpResponse.Content + .ReadAsStringAsync(cancellationToken); + + var content = JsonConvert.DeserializeObject(stringContent); + + if (content == null) + { + throw new InvalidOperationException("Token endpoint returned invalid JSON."); + } + + var error = content["error"]?.ToString(); + var errorDescription = content["error_description"]?.ToString() ?? "Unknown"; + + if (error != null) + { + throw new InvalidOperationException($"{error}: {errorDescription}"); + } + + var accessToken = content["access_token"]?.ToString(); + + if (accessToken == null) + { + throw new NullReferenceException(nameof(accessToken)); + } + + var refreshToken = content["refresh_token"]?.ToString(); + + var idToken = content["id_token"]?.ToString(); + + if (idToken == null) + { + throw new NullReferenceException(nameof(idToken)); + } var settings = new GoogleJsonWebSignature.ValidationSettings { Audience = [ - options.ClientId + this.options.ClientId ] }; var payload = await GoogleJsonWebSignature - .ValidateAsync(flow.AccessToken, settings); + .ValidateAsync(idToken, settings); return new ExternalAuthenticationData { @@ -44,7 +98,8 @@ public override async Task AuthenticateAsync(Implici ExternalToken = new ExternalAuthenticationToken { Name = BuiltInExternalLogInProviderNames.GOOGLE, - Token = flow.AccessToken + Token = accessToken, + RefreshToken = refreshToken } }; } @@ -54,8 +109,51 @@ public override async Task AuthenticateRefreshAsync { ArgumentNullException.ThrowIfNull(refreshToken); - await Task.CompletedTask; + using var httpRequestMessage = new HttpRequestMessage(); + + httpRequestMessage.Method = HttpMethod.Post; + httpRequestMessage.RequestUri = new Uri("https://oauth2.googleapis.com/token"); + + httpRequestMessage.Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = this.options.ClientId, + ["client_secret"] = this.options.ClientSecret, + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken + }); + + var httpResponse = await this.httpClient + .SendAsync(httpRequestMessage, cancellationToken); + + var stringContent = await httpResponse.Content + .ReadAsStringAsync(cancellationToken); + + var content = JsonConvert.DeserializeObject(stringContent); + + var error = content?["error"]?.ToString(); + var errorDescription = content?["error_description"]?.ToString() ?? "Unknown"; - throw new UnauthorizedException(); + if (error != null) + { + throw new UnauthorizedException($"{error}: {errorDescription}"); + } + + var accessToken = content?["access_token"]?.ToString(); + + if (accessToken == null) + { + throw new NullReferenceException(nameof(accessToken)); + } + + // Google does not rotate refresh tokens - a refresh grant response omits refresh_token, so the + // original one (still valid) is carried forward instead of being dropped. + var refreshTokenNew = content?["refresh_token"]?.ToString() ?? refreshToken; + + return new ExternalAuthenticationToken + { + Name = BuiltInExternalLogInProviderNames.GOOGLE, + Token = accessToken, + RefreshToken = refreshTokenNew + }; } } \ 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 9de77dfd..eda107bb 100644 --- a/Nano.App.Api/Mvc/Authentication/AuthTransientRepository.cs +++ b/Nano.App.Api/Mvc/Authentication/AuthTransientRepository.cs @@ -76,6 +76,11 @@ public virtual async Task LogInExternalAsync(string provider var authenticationData = await this.authExternalRepository .AuthenticateAsync(providerName, logInExternal.Flow, cancellationToken); + if (!logInExternal.IsRefreshable) + { + authenticationData.ExternalToken.RefreshToken = null; + } + var claims = logInExternal.TransientClaims .Merge(authenticationData.TransientClaims); diff --git a/Nano.App.Api/Mvc/Authentication/Extensions/ServiceCollectionExtensions.cs b/Nano.App.Api/Mvc/Authentication/Extensions/ServiceCollectionExtensions.cs index ed4aff14..44643667 100644 --- a/Nano.App.Api/Mvc/Authentication/Extensions/ServiceCollectionExtensions.cs +++ b/Nano.App.Api/Mvc/Authentication/Extensions/ServiceCollectionExtensions.cs @@ -213,12 +213,21 @@ private static IServiceCollection AddAuthExternalGoogleRepository(this IServiceC return services; } + services + .AddHttpClient(); + services .AddScoped(x => { var apiOptions = x .GetRequiredService>(); + var httpClientFactory = x + .GetRequiredService(); + + var httpClient = httpClientFactory + .CreateClient(nameof(AuthExternalGoogleRepository)); + var googleOptions = apiOptions.CurrentValue.Authentication.Jwt?.ExternalLogins.Google; if (googleOptions == null) @@ -226,7 +235,7 @@ private static IServiceCollection AddAuthExternalGoogleRepository(this IServiceC throw new NullReferenceException(nameof(apiOptions.CurrentValue.Authentication.Jwt.ExternalLogins.Google)); } - return new AuthExternalGoogleRepository(googleOptions); + return new AuthExternalGoogleRepository(googleOptions, httpClient); }); return services; diff --git a/Nano.App.Api/Nano.App.Api.csproj b/Nano.App.Api/Nano.App.Api.csproj index 34806cc1..701d723a 100644 --- a/Nano.App.Api/Nano.App.Api.csproj +++ b/Nano.App.Api/Nano.App.Api.csproj @@ -7,14 +7,14 @@ - + - + diff --git a/Nano.App.Api/README.md b/Nano.App.Api/README.md index bfac9755..d9449a17 100644 --- a/Nano.App.Api/README.md +++ b/Nano.App.Api/README.md @@ -278,19 +278,39 @@ services: In `Staging` and `Production`, TLS certificates are automatically managed by the [Kubernetes Gateway](https://github.com/Nano-Core/Nano.Azure.Kubernetes/blob/master/Nano.Azure.Kubernetes.Gateway/README.md#nanoazurekubernetesgateway) and [Cert-Manager](https://github.com/Nano-Core/Nano.Azure.Kubernetes/blob/master/Nano.Azure.Kubernetes.CertManager/README.md#nanoazurekubernetescertmanager). -Applications that are exposed publicly just need to define a subdomain and create an `HTTPRoute` Kubernetes resource. +Applications that are exposed publicly just need to define a subdomain and create a pair of `HTTPRoute` Kubernetes resources: one redirecting plain HTTP to HTTPS, and one routing the actual HTTPS traffic to the app. The two always come together. ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{name}}-route + name: {{name}}-route-80 namespace: {{namespace}} spec: parentRefs: - name: {{gateway-name}} + sectionName: http hostnames: - - {{sub-domain}}{{dns-zone-name]] + - {{sub-domain}}{{dns-zone-name}} + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 +``` + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{name}}-route-443 + namespace: {{namespace}} +spec: + parentRefs: + - name: {{gateway-name}} + hostnames: + - {{sub-domain}}{{dns-zone-name}} rules: - matches: - path: @@ -1107,7 +1127,7 @@ The HTTP cache stores a response associated with a request and reuses the stored There are several advantages to reusability. First, since there is no need to deliver the request to the origin server, then the closer the client and cache are, the faster the response will be. The most typical example is when the browser itself stores a cache for browser requests. -Also, when a response is reusable, the origin server does not need to process the request — so it does not need to parse and route the request, +Also, when a response is reusable, the origin server does not need to process the request, so it does not need to parse and route the request, restore the session based on the cookie, query the DB for results, or render the template engine. That reduces the load on the server. > ⚠️ It's recommended to enable this in configuration, then disable for specific actions using `[ResponseCache(...)]`. @@ -1680,13 +1700,13 @@ Console.Read(); In Nano, all authentication features are accessed through a set of repository interfaces. The table below details each supported login type and its corresponding registered interfaces, showing what is available for use in your application. -| Login | Auth Type | Config / Registration Required | Primary Interface | -| ---------------------- | ----------------- | ------------------------------ | ---------------------------- | -| Root | JWT Transient | Jwt, RootLogin | `IAuthRootRepository` | -| Credentials | JWT Identity | Jwt, Identity | `IAuthIdentityRepository` | -| External | JWT Identity | Jwt, ExternalLogins, Identity | `IAuthIdentityRepository` | -| External Transient | JWT Transient | Jwt, ExternalLogins | `IAuthTransientRepository` | -| Api Key | Api Key Identity | Identity, ApiKey | - | +| Login | Auth Type | Config / Registration Required | Primary Interface | +| ---------------------- | ----------------- | ------------------------------ | ------------------------------ | +| Root | JWT Transient | Jwt, RootLogin | `IAuthRootRepository` | +| Credentials | JWT Identity | Jwt, Identity | `IAuthIdentityRepository` | +| External | JWT Identity | Jwt, ExternalLogins, Identity | `IAuthIdentityRepository` | +| External Transient | JWT Transient | Jwt, ExternalLogins | `IAuthTransientRepository` | +| Api Key | Api Key Identity | Identity, ApiKey | (_`IAuthIdentityRepository`_) | Nano supports a statically configured JWT login called `RootLogin`. It is primarily intended for use in `Development` environments when testing services in isolation, but where the application still requires an authenticated user. Another common scenario is when console applications need to authenticate through the Nano API client but do not have @@ -1799,6 +1819,7 @@ Logging in using external authentication in Nano can be achieved either by confi For a built-in provider, the following configuration can be added. **Facebook** +Uses an implicit flow: the client-side SDK obtains the access token directly and sends it straight to Nano; there's no server-side token exchange, and logins can't be refreshed. | Setting | Type | Default | Description | | -------------------------- | ------ | -------- | ----------------------------------- | @@ -1821,11 +1842,14 @@ For a built-in provider, the following configuration can be added. } ``` -`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). +The `Scopes` must include `email` (`public_profile` is granted by default but listing it explicitly is harmless) so Nano can read the `id`, `name` and `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). **Google** +Uses an auth code flow: the frontend redirects the user through Google's sign-in with PKCE, and Nano exchanges the resulting code for tokens itself server-side. +Refreshable if the frontend requests it. | Setting | Type | Default | Description | | -------------------------- | ------ | -------- | ----------------------------------- | @@ -1848,11 +1872,29 @@ requests from the Facebook Graph API; add `user_birthday` too if the `birthday` } ``` -`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). +The `Scopes` must include `openid` (and should include `profile` and `email`). Nano exchanges the authorization code for tokens itself and reads the `name` and `email` claims +from the resulting `id_token`. + +Generate a PKCE `code_verifier` and `code_challenge` pair and a random `state` value, then redirect the user to the following URI to trigger Google's sign-in flow: + +``` +https://accounts.google.com/o/oauth2/v2/auth + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &scope=openid profile email + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +Then send the resulting `code` and the `code_verifier` used to derive `code_challenge` to the external login endpoint. + +The Google Client Id/Secret must be created manually through the [Google Cloud Console](https://console.cloud.google.com)'s OAuth client setup. **Microsoft** +Uses an auth code flow: the frontend redirects the user through Microsoft's sign-in with PKCE, and Nano exchanges the resulting code for tokens itself server-side. +Refreshable if the frontend requests it. | Setting | Type | Default | Description | | -------------------------- | ------ | -------- | ----------------------------------- | @@ -1870,20 +1912,37 @@ no API/CLI path to script this the way there is for Microsoft (see below). "TenantId": null, "ClientId": null, "ClientSecret": null, - "Scopes": [ "openid", "profile", "email" ] + "Scopes": [ "openid", "profile", "email", "offline_access" ] } } } } ``` -`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. +The `Scopes` must include `openid` (and should include `profile` and `email`). Nano reads the login's identity claims `oid`, `name` and `email` from the token response's +`id_token`, which is only returned when `openid` is requested. Add `offline_access` as a fourth entry only if this login should be refreshable. Microsoft silently omits +`refresh_token` from the token response without it. The same scope must also be requested in the frontend's own sign-in redirect below, since consent for it is granted once, +at that initial step. + +Generate a PKCE `code_verifier`/`code_challenge` pair and a random `state` value, then redirect the user to the following URI to trigger Microsoft's sign-in flow: + +``` +https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize + ?client_id={ClientId} + &response_type=code + &redirect_uri={RedirectUri} + &response_mode=query + &scope=openid profile email offline_access + &code_challenge={code_challenge} + &code_challenge_method=S256 + &state={state} +``` + +Then send the resulting `code` and the `code_verifier` used to derive `code_challenge` to the external login endpoint. -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. +Microsoft's Entra ID app registration credentials are provisioned and rotated by the GitHub Actions workflow itself, in a `Setup App Registration` step. The `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: @@ -2327,17 +2386,17 @@ simply derive a concrete controller from one of these base classes. There is no The following endpoints are available in the `BaseAuthController` for managing authentication. Nano only exposes endpoints that match the current configuration; any features that are not configured will not be registered or available in the controller. -| Endpoint | Method | Role | Description | -| ------------------------------------------------ | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/auth/login` | POST | Anonymous | Authenticates a user and returns an access token (JWT). Only exposed when Identity has been configured. | -| `/auth/login/root` | POST | Anonymous | Authenticates the root user from configuration and returns an access token. | -| `/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. | +| Endpoint | Method | Role | Description | +| ------------------------------------------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/auth/login` | POST | Anonymous | Authenticates a user and returns an access token (JWT). Only exposed when Identity has been configured. | +| `/auth/login/root` | POST | Anonymous | Authenticates the root user from configuration and returns an access token. | +| `/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 login via the Authorization header. One endpoint per provider, only without Identity. | +| `/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. | > 📖 Learn more about **[Authentication](#authentication)**. diff --git a/Nano.App.Console/README.md b/Nano.App.Console/README.md index 80eb6d7b..f74de50e 100644 --- a/Nano.App.Console/README.md +++ b/Nano.App.Console/README.md @@ -100,7 +100,7 @@ The `App` section in the configuration defines behavior related to the applicati ## Localization The Nano configuration supports specifying a default `CultureInfo` for console applications, ensuring that culture-sensitive operations -such as date, number, and currency formatting—are applied consistently across the entire application lifecycle. +such as date, number, and currency formatting, are applied consistently across the entire application lifecycle. The `DefaultCultureInfo` will be set to the configured default culture. diff --git a/Nano.App/ApiClient/Requests/Auth/LogInExternalGoogleRequest.cs b/Nano.App/ApiClient/Requests/Auth/LogInExternalGoogleRequest.cs index 47fb71ab..3ebcafe8 100644 --- a/Nano.App/ApiClient/Requests/Auth/LogInExternalGoogleRequest.cs +++ b/Nano.App/ApiClient/Requests/Auth/LogInExternalGoogleRequest.cs @@ -5,4 +5,4 @@ namespace Nano.App.ApiClient.Requests.Auth; /// /// Class for Google external login requests. /// -public class LogInExternalGoogleRequest() : LogInExternalImplicitRequest(BuiltInExternalLogInProviderNames.GOOGLE); \ No newline at end of file +public class LogInExternalGoogleRequest() : LogInExternalAuthCodeRequest(BuiltInExternalLogInProviderNames.GOOGLE); \ No newline at end of file diff --git a/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRequest.cs b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRequest.cs index b834e574..1e331893 100644 --- a/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRequest.cs +++ b/Nano.App/ApiClient/Requests/Auth/LogInExternalTransientGoogleRequest.cs @@ -5,4 +5,4 @@ namespace Nano.App.ApiClient.Requests.Auth; /// /// Class for Google transient external login requests. /// -public class LogInExternalTransientGoogleRequest() : LogInExternalTransientImplicitRequest(BuiltInExternalLogInProviderNames.GOOGLE); \ No newline at end of file +public class LogInExternalTransientGoogleRequest() : LogInExternalTransientAuthCodeRequest(BuiltInExternalLogInProviderNames.GOOGLE); \ No newline at end of file diff --git a/Nano.App/ApiClient/Requests/Identity/AddExternalLoginGoogleRequest.cs b/Nano.App/ApiClient/Requests/Identity/AddExternalLoginGoogleRequest.cs index 324c8cb0..55945845 100644 --- a/Nano.App/ApiClient/Requests/Identity/AddExternalLoginGoogleRequest.cs +++ b/Nano.App/ApiClient/Requests/Identity/AddExternalLoginGoogleRequest.cs @@ -6,5 +6,5 @@ namespace Nano.App.ApiClient.Requests.Identity; /// /// Class for add external login Google request. /// -public class AddExternalLoginGoogleRequest() : AddExternalLoginImplicitRequest(BuiltInExternalLogInProviderNames.GOOGLE) +public class AddExternalLoginGoogleRequest() : AddExternalLoginAuthCodeRequest(BuiltInExternalLogInProviderNames.GOOGLE) where TIdentity : IEquatable; \ No newline at end of file diff --git a/Nano.App/ApiClient/Requests/Identity/SignUpExternalGoogleRequest.cs b/Nano.App/ApiClient/Requests/Identity/SignUpExternalGoogleRequest.cs index 6f2067d8..ef2acbd0 100644 --- a/Nano.App/ApiClient/Requests/Identity/SignUpExternalGoogleRequest.cs +++ b/Nano.App/ApiClient/Requests/Identity/SignUpExternalGoogleRequest.cs @@ -7,6 +7,6 @@ namespace Nano.App.ApiClient.Requests.Identity; /// /// Class for Google external sign-up requests. /// -public class SignUpExternalGoogleRequest() : SignUpExternalImplicitRequest(BuiltInExternalLogInProviderNames.GOOGLE) +public class SignUpExternalGoogleRequest() : SignUpExternalAuthCodeRequest(BuiltInExternalLogInProviderNames.GOOGLE) where TUser : IEntityUser where TIdentity : IEquatable; \ No newline at end of file diff --git a/Nano.App/Nano.App.csproj b/Nano.App/Nano.App.csproj index 40753825..f0750478 100644 --- a/Nano.App/Nano.App.csproj +++ b/Nano.App/Nano.App.csproj @@ -4,7 +4,7 @@ - + diff --git a/Nano.App/README.md b/Nano.App/README.md index 501d002c..507abf49 100644 --- a/Nano.App/README.md +++ b/Nano.App/README.md @@ -103,7 +103,7 @@ public class MyApiClient(ApiClient apiClient) : BaseApiClient(apiClient) or with Identity. ```csharp -public class MyIdentityApiClient(ApiClient apiClient) : BaseIdentityApiClient(apiClient) +public class MyIdentityApiClient(ApiClient apiClient) : BaseIdentityApiClient(apiClient) { } ``` @@ -207,12 +207,12 @@ The following methods are available for Auth operations. | Setting | Parameters | Description | | -------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------- | -| `GetExternalSchemesAsync` | GetExternalSchemesRequest | Executes `auth/external-schemes` to retrieve available external login providers. | +| `GetExternalSchemesAsync` | GetExternalSchemesRequest | Executes `auth/external/schemes` to retrieve available external login providers. | | `LogInAsync` | LogInRequest | Executes `auth/login` to authenticate a user and obtain an access token. | | `LogInRootAsync` | LogInRootRequest | Executes `auth/login/root` to authenticate using root credentials. | | `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. | +| `LogInExternalAsync` | BaseLogInExternalRequest | Executes `auth/login/external/{providerName}` to authenticate via an external provider. | +| `LogInExternalTransientAsync` | BaseLogInExternalRequest | Executes `auth/login/external/{providerName}/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.Data.Abstractions/Nano.Data.Abstractions.csproj b/Nano.Data.Abstractions/Nano.Data.Abstractions.csproj index 3d5b9d48..99430303 100644 --- a/Nano.Data.Abstractions/Nano.Data.Abstractions.csproj +++ b/Nano.Data.Abstractions/Nano.Data.Abstractions.csproj @@ -2,7 +2,7 @@ - + diff --git a/Nano.Data.MySql/Nano.Data.MySql.csproj b/Nano.Data.MySql/Nano.Data.MySql.csproj index fa689fe5..7bdca78b 100644 --- a/Nano.Data.MySql/Nano.Data.MySql.csproj +++ b/Nano.Data.MySql/Nano.Data.MySql.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/Nano.Data.MySql/README.md b/Nano.Data.MySql/README.md index 138df8db..0a0481fa 100644 --- a/Nano.Data.MySql/README.md +++ b/Nano.Data.MySql/README.md @@ -65,7 +65,7 @@ Add the data configuration to `appsettings.json`. "ConnectionString": null, "AuthenticationType": "Credentials", "Repository": { - "UseAutoSave": false, + "UseAutoSave": true, "QueryIncludeDepth": 4 }, "Identity": null, diff --git a/Nano.Data.PostgreSQL/README.md b/Nano.Data.PostgreSQL/README.md index cb2f031a..b14ec20d 100644 --- a/Nano.Data.PostgreSQL/README.md +++ b/Nano.Data.PostgreSQL/README.md @@ -72,7 +72,7 @@ Add the data configuration to `appsettings.json`. "ConnectionString": null, "AuthenticationType": "Credentials", "Repository": { - "UseAutoSave": false, + "UseAutoSave": true, "QueryIncludeDepth": 4 }, "Identity": null, diff --git a/Nano.Data.SqLite/README.md b/Nano.Data.SqLite/README.md index 0f976a90..a6d2b8b4 100644 --- a/Nano.Data.SqLite/README.md +++ b/Nano.Data.SqLite/README.md @@ -65,7 +65,7 @@ Add the data configuration to `appsettings.json`. "DefaultCollation": null, "ConnectionString": "Data Source=/mnt/data/nanoDb.sqlite", "Repository": { - "UseAutoSave": false, + "UseAutoSave": true, "QueryIncludeDepth": 4 }, "Identity": null, @@ -97,7 +97,7 @@ services: ## Kubernetes Add an additional Kubernetes template, `data-storageclass.yaml`, for dynamically provisioning the disk backing the SqLite database file. -> ⚠️ Single-attach (`ReadWriteOnce`) — fine for a `CronJob`, but a multi-replica API/Web app needs `stateful-set.yaml` (`StatefulSet` + `volumeClaimTemplates`), not `deployment.yaml`, so +> ⚠️ Single-attach (`ReadWriteOnce`): fine for a `CronJob`, but a multi-replica API/Web app needs `stateful-set.yaml` (`StatefulSet` + `volumeClaimTemplates`), not `deployment.yaml`, so each replica gets its own (unshared) database file. For one shared database, use a network provider such as **[Nano.Data.MySql](https://github.com/Nano-Core/Nano.Library/blob/master/Nano.Data.MySql/README.md#nanodatamysql)**. For a `CronJob`, or a single-replica Deployment, mount the disk via a static `data-pvc.yaml` `PersistentVolumeClaim` as before. diff --git a/Nano.Data.SqlServer/README.md b/Nano.Data.SqlServer/README.md index 35edeb99..a51ef861 100644 --- a/Nano.Data.SqlServer/README.md +++ b/Nano.Data.SqlServer/README.md @@ -65,7 +65,7 @@ Add the data configuration to `appsettings.json`. "ConnectionString": null, "AuthenticationType": "Credentials", "Repository": { - "UseAutoSave": false, + "UseAutoSave": true, "QueryIncludeDepth": 4 }, "Identity": null, diff --git a/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs b/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs index 6d7b5fe3..c35514fc 100644 --- a/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs +++ b/Nano.Data/Identity/Authentication/BaseAuthIdentityRepository.cs @@ -136,6 +136,11 @@ public virtual async Task LogInExternalAsync(string provider var authenticationData = await this.authExternalRepository .AuthenticateAsync(providerName, logInExternalFlow.Flow, cancellationToken); + if (!logInExternalFlow.IsRefreshable) + { + authenticationData.ExternalToken.RefreshToken = null; + } + var claims = logInExternalFlow.TransientClaims .Merge(authenticationData.TransientClaims); diff --git a/Nano.Data/README.md b/Nano.Data/README.md index bf6d3261..df6e140a 100644 --- a/Nano.Data/README.md +++ b/Nano.Data/README.md @@ -120,7 +120,7 @@ The `Data` section in the configuration defines the data provider and related se "ConnectionString": null, "AuthenticationType": "Credentials", "Repository": { - "UseAutoSave": false, + "UseAutoSave": true, "QueryIncludeDepth": 4 }, "ConnectionPool": null, @@ -173,7 +173,7 @@ access control. "Data": { "Identity": { "TokensExpiration": "24:00:00", - "UseAudit": false, + "UseAudit": "None", "User": { "IsUniqueEmailAddressRequired": true, "IsUniquePhoneNumberRequired": false, @@ -192,12 +192,12 @@ access control. "DefaultLockoutTimeSpan": "00:30:00" }, "Password": { - "RequireDigit": false, - "RequireNonAlphanumeric": false, - "RequireLowercase": false, - "RequireUppercase": false, - "RequiredLength": 5, - "RequiredUniqueCharacters": 5 + "RequireDigit": true, + "RequireNonAlphanumeric": true, + "RequireLowercase": true, + "RequireUppercase": true, + "RequiredLength": 12, + "RequiredUniqueCharacters": 3 }, "ApiKey": { "Secret": null @@ -239,7 +239,7 @@ When identity has been configured the following roles are automatically added. | writer | Authorized to read and write. | | creator | Authorized to create. | | editor | Authorized to update. | -| deleter | Authorized to create. | +| deleter | Authorized to delete. | | identity | Authorized to use identity actions. | | Administrator | Full access to everything. | @@ -601,7 +601,7 @@ identity logic through a single, consistent repository. | `SignOutAsync` | Login | userId, appId | Signs out the currently authenticated user and removes any associated refresh tokens. | | `IsEmailAddressTakenAsync` | Sign Up | emailAddress | Checks whether the specified email address is already registered. Returns true if taken. | | `IsPhoneNumberTakenAsync` | Sign Up | phoneNumber | Checks whether the specified phone number is already registered. Returns true if taken. | -| `GetPasswordOptionsAsync` | Sign Up | — | Retrieves the password configuration options for the identity system, if available. | +| `GetPasswordOptionsAsync` | Sign Up | - | Retrieves the password configuration options for the identity system, if available. | | `SignUpAsync` | Sign Up | signUp | Registers a new user with the specified sign-up information. Returns the created user entity. | | `SignUpExternalAsync` | Sign Up | signUpExternal | Registers a new user using external login provider information. Returns the created user entity. | | `GetIdentityUserAsync` | User | id | Retrieves the identity user by its identifier. Throws if the user is not found. | @@ -662,7 +662,7 @@ identity logic through a single, consistent repository. | `ReplaceRoleClaimAsync` | Api Key Claims | roleId, replaceClaim | Replaces an existing claim of an api key with a new value. | | `AssignOrReplaceRoleClaimAsync` | Api Key Claims | roleId, assignOrReplaceClaim | Assigns a claim to an api key or replaces it if it already exists. | | `RemoveRoleClaimAsync` | Api Key Claims | roleId, removeClaim | Removes a claim from an api key. | -| `GetRolesAsync` | Roles | — | Retrieves all roles in the system. | +| `GetRolesAsync` | Roles | - | Retrieves all roles in the system. | | `CreateRoleAsync` | Roles | roleName | Creates a new role. Returns the created role. | | `DeleteRoleAsync` | Roles | roleName | Deletes an existing role. | | `GetRoleClaimAsync` | Role Claims | roleId, getClaim | Retrieves a specific claim of a role by claim type. | diff --git a/Nano.Eventing/README.md b/Nano.Eventing/README.md index c4f3ab26..1cb8b1ff 100644 --- a/Nano.Eventing/README.md +++ b/Nano.Eventing/README.md @@ -150,14 +150,20 @@ for details. > ⚠️ Share the event model as a NuGet package to ensure a consistent contract between publishers and subscribers. Exchange and queue names are derived automatically from the event type. -Next, to publish an event from one application: +Next, to publish an event from one application, inject `IEventing` and call `PublishAsync`: ```csharp -await this.Eventing - .PublishAsync(new MyEvent +public class MyController(ILogger logger, IEventing eventing) : BaseController(logger) +{ + public async Task DoSomethingAsync() { - Text = "Message from another service" - }); + await eventing + .PublishAsync(new MyEvent + { + Text = "Message from another service" + }); + } +} ``` ⚠️ IEventing also provides a `SubscribeAsync(...)` method, but manual invocation is not required. All `IEventingHandler` implementations are automatically diff --git a/Nano.Eventing/RegisterEventingHandlersTask.cs b/Nano.Eventing/RegisterEventingHandlersTask.cs index 9f03c1f3..10168dfa 100644 --- a/Nano.Eventing/RegisterEventingHandlersTask.cs +++ b/Nano.Eventing/RegisterEventingHandlersTask.cs @@ -44,7 +44,7 @@ public async Task RegisterEventHandlers(IServiceScope serviceScope, IServiceProv .GetProperty(nameof(IEventingHandler.RoutingKey), BindingFlags.Public | BindingFlags.Static)? .GetValue(null); - var overridePrefetchCount = (string?)eventHandlerType + var overridePrefetchCount = (ushort?)eventHandlerType .GetProperty(nameof(IEventingHandler.OverridePrefetchCount), BindingFlags.Public | BindingFlags.Static)? .GetValue(null); diff --git a/Nano.Logging.NLog/Nano.Logging.NLog.csproj b/Nano.Logging.NLog/Nano.Logging.NLog.csproj index ef9def73..dc91a570 100644 --- a/Nano.Logging.NLog/Nano.Logging.NLog.csproj +++ b/Nano.Logging.NLog/Nano.Logging.NLog.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/Nano.Logging.Serilog/README.md b/Nano.Logging.Serilog/README.md index faaf75d6..c601e21b 100644 --- a/Nano.Logging.Serilog/README.md +++ b/Nano.Logging.Serilog/README.md @@ -17,7 +17,7 @@ This package provides the Serilog logging provider for Nano. The provider is preconfigured to write log output to the console using a concise format: ``` -{Timestamp:dd-MM-yyyy HH:mm:ss.ffffff} [{Level:u3}] {Message}{NewLine}{Exception} +{Timestamp:dd-MM-yyyy HH:mm:ss.ffffff} [{Level:u3}] {Message:lj}{NewLine}{Exception} ``` > 📖 Learn more about **[Nano Logging](https://github.com/Nano-Core/Nano.Library/blob/master/Nano.Logging/README.md#nanologging)**. diff --git a/Nano.Logging/README.md b/Nano.Logging/README.md index 8d31dbb9..3821088d 100644 --- a/Nano.Logging/README.md +++ b/Nano.Logging/README.md @@ -52,7 +52,7 @@ The ```Logging``` section in the configuration defines the logging provider and | ------------------------------- | ------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | | `LogLevel` | enum | Information | The default minimum LogLevel used by the logging provider. Values: Debug, Information, Warning, Error, Fatal. | | `LogLevelOverrides` | array | [] | Optional overrides for specific namespaces, allowing different log levels for different parts of the application. | -| `LogLevelOverrides.Namespace` | string | null | The namespace for which this log level override applies. You may prepend an asterisk (`*`) as a wildcard to match multiple namespaces. | +| `LogLevelOverrides.Namespace` | string | null | The namespace (or namespace prefix) for which this log level override applies. Matching behavior is provider-specific: most providers match it as a prefix against the actual namespace, no wildcard character needed. | | `LogLevelOverrides.LogLevel` | enum | Warning | The log level to apply for the specific namespace. Values: Debug, Information, Warning, Error, Fatal. | ```json diff --git a/Nano.Storage/README.md b/Nano.Storage/README.md index 26d84ce0..05ac02db 100644 --- a/Nano.Storage/README.md +++ b/Nano.Storage/README.md @@ -77,7 +77,6 @@ The ```Storage``` section in the configuration defines the storage provider and | Setting | Type | Default | Description | | ------------------ | ------ | ----------- | ------------------------------------------------------------------------------------------- | | `ShareName` | string | null | The logical container, share, or bucket name used for file storage. | -| `Credentials` | object | null | Optional. The credential or account of the storage provider. | | `HealthCheck` | object | null | Storage health check. _Only relevant for `NanoApiApplication` and `NanoWebApplication`_.. | ```json diff --git a/README.md b/README.md index 07dc51ab..497c9159 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,8 @@ In the following table shows the different files and folder strucutre. | `.kubernetes/autoscaler.yaml` | ✓ | ✓ | ✗ | [Horizontal Pod Autoscaler (HPA)](https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/) specification. | | `.kubernetes/deployment.yaml` | ✓ | ✓ | ✗ | [Deployment](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/) specification. | | `.kubernetes/service.yaml` | ✓ | ✓ | ✗ | [Service](https://kubernetes.io/docs/concepts/services-networking/service/) exposure specification. | -| `.kubernetes/httproute.yaml` | (✓) | (✓) | ✗ | [HTTPRoute](https://kubernetes.io/docs/concepts/services-networking/gateway/#api-kind-httproute) specification _(Optional)_. | +| `.kubernetes/httproute-80.yaml` | (✓) | (✓) | ✗ | [HTTPRoute](https://kubernetes.io/docs/concepts/services-networking/gateway/#api-kind-httproute) redirecting HTTP to HTTPS _(Optional, public-facing apps only, always paired with httproute-443.yaml)_. | +| `.kubernetes/httproute-443.yaml` | (✓) | (✓) | ✗ | [HTTPRoute](https://kubernetes.io/docs/concepts/services-networking/gateway/#api-kind-httproute) routing HTTPS traffic to the app _(Optional, public-facing apps only)_. | | `.kubernetes/cronjob.yaml` | ✗ | ✗ | ✓ | [CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/) specification. | | `.tests/Tests.{name}/Tests.{name}.csproj` | ✓ | ✓ | ✓ | Test project, which is empty by default and included to demonstrate the structure and where unit or integration tests should be added. | | `.tests/Tests.{name}/Properties/DoNotParallelize.cs`| ✓ | ✓ | ✓ | Ensures tests are not Parallelized. |