From 404f2c81904606395c51ba667a6d777f2202f8a7 Mon Sep 17 00:00:00 2001 From: vivet Date: Tue, 13 Oct 2026 18:07:42 +0200 Subject: [PATCH 1/2] Added xms_edov claim for Microsoft external logins fpr Api.Auth.External.Microsoft --- .github/workflows/build-and-deploy.yml | 2 +- .../.github/workflows/build-and-deploy.yml | 3 +++ Api.Auth.External.Microsoft/README.md | 11 +++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 9e0f7292..bc455802 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -7,7 +7,7 @@ on: branches: - master env: - VERSION: 10.0.9 + VERSION: 10.0.10 jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml index 73185b52..6098cdb0 100644 --- a/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml +++ b/Api.Auth.External.Microsoft/.github/workflows/build-and-deploy.yml @@ -128,6 +128,7 @@ jobs: $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = "AzureADandPersonalMicrosoftAccount"; $env:AUTH_MICROSOFT_TENANT_ID = "common"; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -136,6 +137,7 @@ jobs: --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -146,6 +148,7 @@ jobs: --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } diff --git a/Api.Auth.External.Microsoft/README.md b/Api.Auth.External.Microsoft/README.md index a2eaf759..0ac70b2a 100644 --- a/Api.Auth.External.Microsoft/README.md +++ b/Api.Auth.External.Microsoft/README.md @@ -78,12 +78,14 @@ $env:REDIRECT_URI = "http://localhost/auth"; $env:SECRET_DISPLAY_NAME = "nano-lesson-secret"; $env:TENANT_ID = "common"; +$env:OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:APP_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience AzureADandPersonalMicrosoftAccount ` --requested-access-token-version 2 ` + --optional-claims $env:OPTIONAL_CLAIMS ` --web-redirect-uris $env:REDIRECT_URI; $env:CLIENT_SECRET = az ad app credential reset ` @@ -98,6 +100,10 @@ don't require any Graph API permission at all, since they only control what ends The app registration is created with sign-in audience `AzureADandPersonalMicrosoftAccount` (any work/school or personal Microsoft account), which is why the authorize URL above uses `common` as the tenant, and why `TenantId` in the configuration below is the literal `common` rather than a real tenant GUID. Personal accounts also require access token version 2, set with `--requested-access-token-version 2`. +The app registration also requests the `xms_edov` optional claim in the `id_token`. Microsoft does not verify the email of a work or school account, so Nano only +accepts a sign-in whose `id_token` has an `email` claim and `xms_edov` set to true. Personal Microsoft accounts are accepted without it, because Microsoft verifies +their email itself. A work or school user without a mail address set cannot sign in. + > ⚠️ The client secret is a real credential for your own Azure tenant. Don't commit a real value into `appsettings.Development.json`, copy the script's output in locally, and keep it out of source control (e.g. via `dotnet user-secrets`, or simply not staging the change). @@ -219,6 +225,7 @@ env: run: | $env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app"; $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -227,6 +234,7 @@ env: --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience AzureADandPersonalMicrosoftAccount ` --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -235,6 +243,9 @@ env: { az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` + --sign-in-audience AzureADandPersonalMicrosoftAccount ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } From a9d4c02c3188557e4c5a685deba2ea33f775b81a Mon Sep 17 00:00:00 2001 From: vivet Date: Wed, 14 Oct 2026 11:42:03 +0200 Subject: [PATCH 2/2] Updated Skills, prompts and AGENTS.md --- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ApiClients.Audit/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ApiClients.Entity/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ApiClients.RootLogIn/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ApiClients/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Auth.External.Custom/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Auth.External.Microsoft/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Auth.RootLogin/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Authorization/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ContentNegotiation/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Cookies/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.CustomConfigSection/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.CustomMiddleware/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.CustomService/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Audit/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.EntityEvents/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Identity.Auth.ApiKey/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- .../AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Identity.Auth.Jwt/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Identity/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.InMemory/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.LazyLoading/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.MySql.Collation/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.MySql.Mappings/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.MySql.Spatial/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.MySql.StoredProcedures/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.MySql.Views/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.MySql/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.PostgreSQL.Spatial/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.PostgreSQL/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Repository.AutoSave/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Repository.Includes/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.SoftDelete/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.SqLite/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.SqlServer.Spatial/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.SqlServer/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Data.Triggers/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Documentation.Csp/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Documentation/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ErrorHandling/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Eventing.RabbitMq/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.HealthChecks/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Hosting.Http/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Hosting.Https/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Hosting.MultipartLimits/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Localization/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Logging.Log4Net/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Logging.Microsoft/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Logging.NLog/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Logging.Serilog/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Metrics/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.MultipartJson/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- .../AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- .../AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.Cors/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.ForwardedHeaders/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.FrameOptions/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.Hsts/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.ReferrerPolicy/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.Robots/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.PolicyHeaders.XssProtection/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.RequestTracing/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ResponseCache/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.ResponseCompression/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Session/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.StartupTasks/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.StaticFiles/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Storage.Azure/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Storage.Local/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.TimeZone/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.Versioning/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api.VirusScan/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Api._Blank/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.CustomConfigSection/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.CustomService/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Data.InMemory/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Data.MySql/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Data.PostgreSQL/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Data.SqLite/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Data.SqlServer/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Eventing.RabbitMq/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.ExceptionHandling/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Localization/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Logging.Log4Net/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Logging.Microsoft/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Logging.NLog/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Logging.Serilog/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.StartupTasks/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Storage.Azure/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Storage.Local/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console.Workers/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Console._Blank/AGENTS.md | 19 ++++++++++++++- .../SKILL.md | 23 ++++++++++++++++++- .../skills/nano-add-data-provider/SKILL.md | 4 ++-- ...ano-add-authentication-microsoft.prompt.md | 23 ++++++++++++++++++- .../prompts/nano-add-data-provider.prompt.md | 4 ++-- Web._Blank/AGENTS.md | 19 ++++++++++++++- 460 files changed, 6072 insertions(+), 644 deletions(-) diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients.Audit/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients.Audit/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Audit/AGENTS.md b/Api.ApiClients.Audit/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ApiClients.Audit/AGENTS.md +++ b/Api.ApiClients.Audit/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients.Entity/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients.Entity/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.Entity/AGENTS.md b/Api.ApiClients.Entity/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ApiClients.Entity/AGENTS.md +++ b/Api.ApiClients.Entity/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients.RootLogIn/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients.RootLogIn/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients.RootLogIn/AGENTS.md b/Api.ApiClients.RootLogIn/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ApiClients.RootLogIn/AGENTS.md +++ b/Api.ApiClients.RootLogIn/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ApiClients/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md b/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ApiClients/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ApiClients/AGENTS.md b/Api.ApiClients/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ApiClients/AGENTS.md +++ b/Api.ApiClients/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md b/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Custom/AGENTS.md b/Api.Auth.External.Custom/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Auth.External.Custom/AGENTS.md +++ b/Api.Auth.External.Custom/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Auth.External.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md b/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Auth.External.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.External.Microsoft/AGENTS.md b/Api.Auth.External.Microsoft/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Auth.External.Microsoft/AGENTS.md +++ b/Api.Auth.External.Microsoft/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Auth.RootLogin/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md b/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Auth.RootLogin/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Auth.RootLogin/AGENTS.md b/Api.Auth.RootLogin/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Auth.RootLogin/AGENTS.md +++ b/Api.Auth.RootLogin/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Authorization/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md b/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Authorization/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Authorization/AGENTS.md b/Api.Authorization/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Authorization/AGENTS.md +++ b/Api.Authorization/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ContentNegotiation/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md b/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ContentNegotiation/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ContentNegotiation/AGENTS.md b/Api.ContentNegotiation/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ContentNegotiation/AGENTS.md +++ b/Api.ContentNegotiation/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Cookies/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md b/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Cookies/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Cookies/AGENTS.md b/Api.Cookies/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Cookies/AGENTS.md +++ b/Api.Cookies/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md b/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md b/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomConfigSection/AGENTS.md b/Api.CustomConfigSection/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.CustomConfigSection/AGENTS.md +++ b/Api.CustomConfigSection/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md b/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomMiddleware/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md b/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomMiddleware/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomMiddleware/AGENTS.md b/Api.CustomMiddleware/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.CustomMiddleware/AGENTS.md +++ b/Api.CustomMiddleware/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md b/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.CustomService/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md b/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.CustomService/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.CustomService/AGENTS.md b/Api.CustomService/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.CustomService/AGENTS.md +++ b/Api.CustomService/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Audit/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Audit/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Audit/AGENTS.md b/Api.Data.Audit/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Audit/AGENTS.md +++ b/Api.Data.Audit/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.EntityEvents/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.EntityEvents/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.EntityEvents/AGENTS.md b/Api.Data.EntityEvents/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.EntityEvents/AGENTS.md +++ b/Api.Data.EntityEvents/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity.Auth.ApiKey/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity.Auth.ApiKey/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.ApiKey/AGENTS.md b/Api.Data.Identity.Auth.ApiKey/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Identity.Auth.ApiKey/AGENTS.md +++ b/Api.Data.Identity.Auth.ApiKey/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity.Auth.External.Custom/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity.Auth.External.Custom/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.External.Custom/AGENTS.md b/Api.Data.Identity.Auth.External.Custom/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Identity.Auth.External.Custom/AGENTS.md +++ b/Api.Data.Identity.Auth.External.Custom/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity.Auth.Jwt/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity.Auth.Jwt/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity.Auth.Jwt/AGENTS.md b/Api.Data.Identity.Auth.Jwt/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Identity.Auth.Jwt/AGENTS.md +++ b/Api.Data.Identity.Auth.Jwt/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Identity/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Identity/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Identity/AGENTS.md b/Api.Data.Identity/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Identity/AGENTS.md +++ b/Api.Data.Identity/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.InMemory/AGENTS.md b/Api.Data.InMemory/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.InMemory/AGENTS.md +++ b/Api.Data.InMemory/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.LazyLoading/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.LazyLoading/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.LazyLoading/AGENTS.md b/Api.Data.LazyLoading/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.LazyLoading/AGENTS.md +++ b/Api.Data.LazyLoading/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Collation/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Collation/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Collation/AGENTS.md b/Api.Data.MySql.Collation/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.MySql.Collation/AGENTS.md +++ b/Api.Data.MySql.Collation/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Mappings/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Mappings/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Mappings/AGENTS.md b/Api.Data.MySql.Mappings/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.MySql.Mappings/AGENTS.md +++ b/Api.Data.MySql.Mappings/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Spatial/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Spatial/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Spatial/AGENTS.md b/Api.Data.MySql.Spatial/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.MySql.Spatial/AGENTS.md +++ b/Api.Data.MySql.Spatial/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.StoredProcedures/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.StoredProcedures/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.StoredProcedures/AGENTS.md b/Api.Data.MySql.StoredProcedures/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.MySql.StoredProcedures/AGENTS.md +++ b/Api.Data.MySql.StoredProcedures/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql.Views/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql.Views/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql.Views/AGENTS.md b/Api.Data.MySql.Views/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.MySql.Views/AGENTS.md +++ b/Api.Data.MySql.Views/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.MySql/AGENTS.md b/Api.Data.MySql/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.MySql/AGENTS.md +++ b/Api.Data.MySql/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.PostgreSQL.Spatial/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.PostgreSQL.Spatial/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL.Spatial/AGENTS.md b/Api.Data.PostgreSQL.Spatial/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.PostgreSQL.Spatial/AGENTS.md +++ b/Api.Data.PostgreSQL.Spatial/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.PostgreSQL/AGENTS.md b/Api.Data.PostgreSQL/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.PostgreSQL/AGENTS.md +++ b/Api.Data.PostgreSQL/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Repository.AutoSave/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Repository.AutoSave/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.AutoSave/AGENTS.md b/Api.Data.Repository.AutoSave/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Repository.AutoSave/AGENTS.md +++ b/Api.Data.Repository.AutoSave/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Repository.Includes/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Repository.Includes/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Repository.Includes/AGENTS.md b/Api.Data.Repository.Includes/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Repository.Includes/AGENTS.md +++ b/Api.Data.Repository.Includes/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SoftDelete/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SoftDelete/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SoftDelete/AGENTS.md b/Api.Data.SoftDelete/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.SoftDelete/AGENTS.md +++ b/Api.Data.SoftDelete/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqLite/AGENTS.md b/Api.Data.SqLite/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.SqLite/AGENTS.md +++ b/Api.Data.SqLite/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SqlServer.Spatial/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SqlServer.Spatial/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer.Spatial/AGENTS.md b/Api.Data.SqlServer.Spatial/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.SqlServer.Spatial/AGENTS.md +++ b/Api.Data.SqlServer.Spatial/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.SqlServer/AGENTS.md b/Api.Data.SqlServer/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.SqlServer/AGENTS.md +++ b/Api.Data.SqlServer/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Data.Triggers/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md b/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Data.Triggers/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Data.Triggers/AGENTS.md b/Api.Data.Triggers/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Data.Triggers/AGENTS.md +++ b/Api.Data.Triggers/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Documentation.Csp/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md b/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Documentation.Csp/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation.Csp/AGENTS.md b/Api.Documentation.Csp/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Documentation.Csp/AGENTS.md +++ b/Api.Documentation.Csp/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Documentation/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md b/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Documentation/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Documentation/AGENTS.md b/Api.Documentation/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Documentation/AGENTS.md +++ b/Api.Documentation/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ErrorHandling/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md b/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ErrorHandling/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ErrorHandling/AGENTS.md b/Api.ErrorHandling/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ErrorHandling/AGENTS.md +++ b/Api.ErrorHandling/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md b/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Eventing.RabbitMq/AGENTS.md b/Api.Eventing.RabbitMq/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Eventing.RabbitMq/AGENTS.md +++ b/Api.Eventing.RabbitMq/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md b/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.HealthChecks/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md b/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.HealthChecks/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.HealthChecks/AGENTS.md b/Api.HealthChecks/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.HealthChecks/AGENTS.md +++ b/Api.HealthChecks/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Hosting.Http/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md b/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Hosting.Http/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Http/AGENTS.md b/Api.Hosting.Http/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Hosting.Http/AGENTS.md +++ b/Api.Hosting.Http/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Hosting.Https/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md b/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Hosting.Https/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.Https/AGENTS.md b/Api.Hosting.Https/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Hosting.Https/AGENTS.md +++ b/Api.Hosting.Https/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Hosting.MultipartLimits/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Hosting.MultipartLimits/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Hosting.MultipartLimits/AGENTS.md b/Api.Hosting.MultipartLimits/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Hosting.MultipartLimits/AGENTS.md +++ b/Api.Hosting.MultipartLimits/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Localization/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md b/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Localization/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Localization/AGENTS.md b/Api.Localization/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Localization/AGENTS.md +++ b/Api.Localization/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Log4Net/AGENTS.md b/Api.Logging.Log4Net/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Logging.Log4Net/AGENTS.md +++ b/Api.Logging.Log4Net/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Microsoft/AGENTS.md b/Api.Logging.Microsoft/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Logging.Microsoft/AGENTS.md +++ b/Api.Logging.Microsoft/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.NLog/AGENTS.md b/Api.Logging.NLog/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Logging.NLog/AGENTS.md +++ b/Api.Logging.NLog/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md b/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Logging.Serilog/AGENTS.md b/Api.Logging.Serilog/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Logging.Serilog/AGENTS.md +++ b/Api.Logging.Serilog/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Metrics/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md b/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Metrics/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Metrics/AGENTS.md b/Api.Metrics/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Metrics/AGENTS.md +++ b/Api.Metrics/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md b/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.MultipartJson/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md b/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.MultipartJson/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.MultipartJson/AGENTS.md b/Api.MultipartJson/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.MultipartJson/AGENTS.md +++ b/Api.MultipartJson/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ContentSecurityPolicy/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ContentTypeOptions/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md +++ b/Api.PolicyHeaders.ContentTypeOptions/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.Cors/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.Cors/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Cors/AGENTS.md b/Api.PolicyHeaders.Cors/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.Cors/AGENTS.md +++ b/Api.PolicyHeaders.Cors/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ForwardedHeaders/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md +++ b/Api.PolicyHeaders.ForwardedHeaders/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.FrameOptions/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.FrameOptions/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.FrameOptions/AGENTS.md b/Api.PolicyHeaders.FrameOptions/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.FrameOptions/AGENTS.md +++ b/Api.PolicyHeaders.FrameOptions/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.Hsts/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.Hsts/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Hsts/AGENTS.md b/Api.PolicyHeaders.Hsts/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.Hsts/AGENTS.md +++ b/Api.PolicyHeaders.Hsts/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.ReferrerPolicy/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md +++ b/Api.PolicyHeaders.ReferrerPolicy/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.Robots/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.Robots/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.Robots/AGENTS.md b/Api.PolicyHeaders.Robots/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.Robots/AGENTS.md +++ b/Api.PolicyHeaders.Robots/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.PolicyHeaders.XssProtection/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.PolicyHeaders.XssProtection/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.PolicyHeaders.XssProtection/AGENTS.md b/Api.PolicyHeaders.XssProtection/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.PolicyHeaders.XssProtection/AGENTS.md +++ b/Api.PolicyHeaders.XssProtection/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md b/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.RequestTracing/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md b/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.RequestTracing/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.RequestTracing/AGENTS.md b/Api.RequestTracing/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.RequestTracing/AGENTS.md +++ b/Api.RequestTracing/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ResponseCache/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md b/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ResponseCache/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCache/AGENTS.md b/Api.ResponseCache/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ResponseCache/AGENTS.md +++ b/Api.ResponseCache/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md b/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.ResponseCompression/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md b/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.ResponseCompression/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.ResponseCompression/AGENTS.md b/Api.ResponseCompression/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.ResponseCompression/AGENTS.md +++ b/Api.ResponseCompression/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Session/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Session/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Session/.github/prompts/nano-add-data-provider.prompt.md b/Api.Session/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Session/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Session/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Session/AGENTS.md b/Api.Session/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Session/AGENTS.md +++ b/Api.Session/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md b/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md b/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StartupTasks/AGENTS.md b/Api.StartupTasks/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.StartupTasks/AGENTS.md +++ b/Api.StartupTasks/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md b/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.StaticFiles/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md b/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.StaticFiles/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.StaticFiles/AGENTS.md b/Api.StaticFiles/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.StaticFiles/AGENTS.md +++ b/Api.StaticFiles/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md b/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Azure/AGENTS.md b/Api.Storage.Azure/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Storage.Azure/AGENTS.md +++ b/Api.Storage.Azure/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md b/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Storage.Local/AGENTS.md b/Api.Storage.Local/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Storage.Local/AGENTS.md +++ b/Api.Storage.Local/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md b/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.TimeZone/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md b/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.TimeZone/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.TimeZone/AGENTS.md b/Api.TimeZone/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.TimeZone/AGENTS.md +++ b/Api.TimeZone/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md b/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.Versioning/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md b/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.Versioning/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.Versioning/AGENTS.md b/Api.Versioning/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.Versioning/AGENTS.md +++ b/Api.Versioning/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md b/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api.VirusScan/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md b/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api.VirusScan/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api.VirusScan/AGENTS.md b/Api.VirusScan/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api.VirusScan/AGENTS.md +++ b/Api.VirusScan/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md b/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Api._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md b/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Api._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Api._Blank/AGENTS.md b/Api._Blank/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Api._Blank/AGENTS.md +++ b/Api._Blank/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md b/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.CustomConfigSection/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md b/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.CustomConfigSection/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomConfigSection/AGENTS.md b/Console.CustomConfigSection/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.CustomConfigSection/AGENTS.md +++ b/Console.CustomConfigSection/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md b/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.CustomService/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md b/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.CustomService/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.CustomService/AGENTS.md b/Console.CustomService/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.CustomService/AGENTS.md +++ b/Console.CustomService/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.InMemory/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.InMemory/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.InMemory/AGENTS.md b/Console.Data.InMemory/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Data.InMemory/AGENTS.md +++ b/Console.Data.InMemory/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.MySql/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.MySql/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.MySql/AGENTS.md b/Console.Data.MySql/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Data.MySql/AGENTS.md +++ b/Console.Data.MySql/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.PostgreSQL/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.PostgreSQL/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.PostgreSQL/AGENTS.md b/Console.Data.PostgreSQL/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Data.PostgreSQL/AGENTS.md +++ b/Console.Data.PostgreSQL/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.SqLite/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.SqLite/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqLite/AGENTS.md b/Console.Data.SqLite/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Data.SqLite/AGENTS.md +++ b/Console.Data.SqLite/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Data.SqlServer/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md b/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Data.SqlServer/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Data.SqlServer/AGENTS.md b/Console.Data.SqlServer/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Data.SqlServer/AGENTS.md +++ b/Console.Data.SqlServer/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Eventing.RabbitMq/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md b/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Eventing.RabbitMq/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Eventing.RabbitMq/AGENTS.md b/Console.Eventing.RabbitMq/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Eventing.RabbitMq/AGENTS.md +++ b/Console.Eventing.RabbitMq/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md b/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.ExceptionHandling/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md b/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.ExceptionHandling/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.ExceptionHandling/AGENTS.md b/Console.ExceptionHandling/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.ExceptionHandling/AGENTS.md +++ b/Console.ExceptionHandling/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Localization/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md b/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Localization/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Localization/AGENTS.md b/Console.Localization/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Localization/AGENTS.md +++ b/Console.Localization/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.Log4Net/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.Log4Net/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Log4Net/AGENTS.md b/Console.Logging.Log4Net/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Logging.Log4Net/AGENTS.md +++ b/Console.Logging.Log4Net/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.Microsoft/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.Microsoft/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Microsoft/AGENTS.md b/Console.Logging.Microsoft/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Logging.Microsoft/AGENTS.md +++ b/Console.Logging.Microsoft/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.NLog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.NLog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.NLog/AGENTS.md b/Console.Logging.NLog/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Logging.NLog/AGENTS.md +++ b/Console.Logging.NLog/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Logging.Serilog/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md b/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Logging.Serilog/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Logging.Serilog/AGENTS.md b/Console.Logging.Serilog/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Logging.Serilog/AGENTS.md +++ b/Console.Logging.Serilog/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md b/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.StartupTasks/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md b/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.StartupTasks/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.StartupTasks/AGENTS.md b/Console.StartupTasks/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.StartupTasks/AGENTS.md +++ b/Console.StartupTasks/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Storage.Azure/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md b/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Storage.Azure/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Azure/AGENTS.md b/Console.Storage.Azure/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Storage.Azure/AGENTS.md +++ b/Console.Storage.Azure/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Storage.Local/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md b/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Storage.Local/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Storage.Local/AGENTS.md b/Console.Storage.Local/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Storage.Local/AGENTS.md +++ b/Console.Storage.Local/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md b/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console.Workers/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md b/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console.Workers/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console.Workers/AGENTS.md b/Console.Workers/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console.Workers/AGENTS.md +++ b/Console.Workers/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md b/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Console._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md b/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Console._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Console._Blank/AGENTS.md b/Console._Blank/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Console._Blank/AGENTS.md +++ b/Console._Blank/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: diff --git a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md index ae2978f1..52a08f8e 100644 --- a/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-authentication-microsoft/SKILL.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below — fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose — the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` — the other diff --git a/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md b/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md index 5f98d41e..eb27cca8 100644 --- a/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md +++ b/Web._Blank/.claude/skills/nano-add-data-provider/SKILL.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` — e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` — e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) — one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md index ad371139..9db098ad 100644 --- a/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-authentication-microsoft.prompt.md @@ -53,6 +53,13 @@ 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` | + **Whichever audience is chosen, the app registration must also request the `xms_edov` optional claim** + in its `id_token` (the scripts below do this with `--optional-claims`). Microsoft does not verify the + `email` of a work or school account, so Nano only accepts a sign-in whose token has `email` and + `xms_edov` (email domain owner verified) set to true, and rejects it otherwise. Without the optional + claim every work or school sign-in of the app is rejected. Only personal Microsoft accounts need no claim + (all of them under `consumers`, and the personal ones under `common`): Nano accepts them because Microsoft verifies their email itself. See AGENTS.md's Authentication section for why. + 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 @@ -136,10 +143,13 @@ $env:APP_DISPLAY_NAME = "{service-name}-app-development"; $env:AUTH_MICROSOFT_REDIRECT_URI = ""; $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; +$env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -159,6 +169,11 @@ $env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset ` - `{SignInAudienceLiteral}`/`{TenantIdLiteral}` are the exact same literals used in the CI step below - fill them in once, from step 4's table above, matching whatever was decided for this app. No switch statement here either, same reasoning as the CI step. +- `--requested-access-token-version 2` is required by Microsoft when the audience allows personal Microsoft + accounts, and harmless for the other audiences, so it is always set, like the optional claims. +- The optional claims JSON is passed as is by PowerShell 7.3 or later (the default for `pwsh` on the CI + runners). Windows PowerShell 5.1 strips the inner quotes, so run it in `pwsh`, or put the same JSON in a + file and pass `--optional-claims @file.json`. - `AUTH_MICROSOFT_REDIRECT_URI` is left empty in the snippet on purpose - the developer fills in their own local callback URL before running it. Don't fill in a guessed value. - After running it, the developer fills the three values into the keys the `.docker/.env` step above @@ -206,6 +221,7 @@ Kubernetes step): $env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss"); $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral}; $env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral}; + $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS = '{"idToken":[{"name":"xms_edov","essential":false}]}'; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; if (-not $env:AUTH_MICROSOFT_CLIENT_ID) @@ -213,6 +229,8 @@ Kubernetes step): az ad app create ` --display-name $env:APP_DISPLAY_NAME ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; $env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv; @@ -222,6 +240,8 @@ Kubernetes step): az ad app update ` --id $env:AUTH_MICROSOFT_CLIENT_ID ` --sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE ` + --requested-access-token-version 2 ` + --optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS ` --web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI; } @@ -251,7 +271,8 @@ Kubernetes step): What this does, and why it's shaped this way: - **Idempotent app registration.** Looks the app up by display name first; creates it only if - missing, otherwise just keeps its redirect URI/audience in sync. + missing, otherwise just keeps its redirect URI/audience in sync. The `xms_edov` optional claim is set + on both paths, so an existing registration gets it on the next deploy. - **`AUTH_MICROSOFT_SIGNIN_AUDIENCE` and `AUTH_MICROSOFT_TENANT_ID` are each literals filled in once, at authoring time, not computed at runtime.** Only `AzureADMyOrg` uses the real tenant GUID (`$env:AZURE_TENANT_ID`, the workflow's own tenant) for `AUTH_MICROSOFT_TENANT_ID` - the other diff --git a/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md b/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md index 8c893c7b..f617ab71 100644 --- a/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md +++ b/Web._Blank/.github/prompts/nano-add-data-provider.prompt.md @@ -38,8 +38,8 @@ without breaking what's already there. generic arguments must agree with it. 6. **Database name.** Derive it from the application's own name, not the solution/repo name and not a copy-pasted value from another app: strip any `Svc.`/`Api.`/`Web.` prefix, lowercase the - first letter of what's left, and append `Db` - e.g. `Svc.Orders` → `ordersDb`, `Api.Backoffice` - → `backofficeDb`. Use this exact value everywhere a database name appears for this app (local + first letter of what's left, and append `Db` - e.g. an app named `Svc.` or `Api.` + gets `Db`. Use this exact value everywhere a database name appears for this app (local `ConnectionString`, `POSTGRES_DB`, the SqLite file name, and the Staging/Production `SQL_NAME` below) - one derived name, reused consistently, not decided separately per environment. Don't reuse another app's prefix/pattern by copying its CI workflow or `appsettings.Development.json` diff --git a/Web._Blank/AGENTS.md b/Web._Blank/AGENTS.md index db5d8bba..e8e8b237 100644 --- a/Web._Blank/AGENTS.md +++ b/Web._Blank/AGENTS.md @@ -1620,7 +1620,7 @@ 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` | `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`). | +| `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`. Nano only accepts a Google sign-in whose email Google reports as verified (`email_verified`), and rejects it with `401` otherwise. | 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.** @@ -1655,6 +1655,23 @@ secret, which needs periodic rotation) can be fully scripted with the Azure CLI, 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. +⚠ **A Microsoft work or school account's `email` is not verified by Microsoft, unless the `xms_edov` claim says so.** +In Entra ID the `email` attribute is set by the tenant's own administrator and nothing checks that they own the +domain. With a sign-in audience that accepts any organization (`organizations`/`common`), anyone can create a tenant, +give a user any email address, and sign in with it. So Nano only accepts a Microsoft sign-in when the `id_token` +carries an `email` claim **and** `xms_edov` (email domain owner verified, true when the user's tenant has verified the +email's domain) is true, and otherwise rejects it with `401`. A personal Microsoft account (its `tid` is Microsoft's +personal-accounts tenant) is accepted without `xms_edov`, because Microsoft itself verifies the email of a personal +account. There is no setting to turn this off. `xms_edov` is an optional claim, not part of the default token: the app +registration must request it (the `nano-add-authentication-microsoft` skill does). A user whose account has no `email` +(no mail address set) cannot sign in with Microsoft. + +An existing login is always matched on the Microsoft `oid`, never on the email. The email matters where an app uses it to +decide who someone is: `signup/external/{providerName}` creates a new user with it, and so does any app code that claims +an existing user by `EmailAddress` (for example an invited user). **Never link or claim an account by an external email +address alone unless the provider verified it**; Nano's Microsoft provider guarantees that, but a custom +`BaseAuthExternalRepository` must do the same itself. + **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: