Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/build-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
branches:
- master
env:
VERSION: 10.0.9
VERSION: 10.0.10
jobs:
build-and-deploy:
runs-on: ubuntu-latest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -206,13 +221,16 @@ 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)
{
az ad app create `
--display-name $env:APP_DISPLAY_NAME `
--sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE `
--requested-access-token-version 2 `
--optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS `
--web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI;

$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<Name>` or `Api.<Name>`
gets `<name>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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -206,13 +221,16 @@ 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)
{
az ad app create `
--display-name $env:APP_DISPLAY_NAME `
--sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE `
--requested-access-token-version 2 `
--optional-claims $env:AUTH_MICROSOFT_OPTIONAL_CLAIMS `
--web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI;

$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<Name>` or `Api.<Name>`
gets `<name>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`
Expand Down
19 changes: 18 additions & 1 deletion Api.ApiClients.Audit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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<TFlow>` 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:

Expand Down
Loading
Loading