Prompt file imported from Nano-Core/Nano.Library (
.github/prompts/nano-add-authentication-microsoft.prompt.md). Copyright stays with the author.
Nano add Microsoft authentication
Configures the built-in Microsoft external login provider (Jwt.ExternalLogins.Microsoft) on an
existing Nano API/Web application. Read AGENTS.md's #### Authentication section first, especially
the "External login providers" table - it documents the flow type (AuthCodeFlow), why Scopes
must include openid, and why Microsoft (unlike Facebook/Google) has a scriptable credential setup
at all. This skill does not repeat that, only how to apply it.
Prerequisite: Jwt must already be configured. ExternalLogins is a sub-section of
Authentication:Jwt, not a standalone auth mode - if the app has no Jwt config or AuthController
yet, stop and point the user at nano-add-authentication-jwt first (it covers persistent vs.
transient and the AuthController itself; this skill only adds the Microsoft-specific piece under
ExternalLogins).
This always wires up all three environments - Development, Staging, and Production - there's no partial-scope option to choose. Development gets a one-time local app-registration script; Staging/Production get the self-provisioning CI step. Don't ask the user which environments to cover, only the questions below that actually vary per app.
Facebook/Google are explicitly out of scope for this skill. They have no CLI/API path for
creating their app credentials (created by hand through each provider's own developer console), so
there's nothing to script the way there is for Microsoft's Entra ID app registration. If the user
asks for Facebook or Google, configure Jwt.ExternalLogins.Facebook/.Google by hand per AGENTS.md's
table and ask how they want the secret stored for Staging/Production - don't invent a Kubernetes/CI
convention for those the way this skill does for Microsoft.
Before making any change, determine
-
Is
Jwt(and theAuthController) already configured? If not, stop - see the prerequisite above. -
Persistent or transient login? Check whether Identity is configured. Doesn't change anything this skill does (the
Jwt.ExternalLogins.Microsoftconfig is identical either way) - it only changes which endpoint ultimately exposes the login per AGENTS.md's sub-repository table (AuthTransientRepositoryvs. the persistent equivalent). Not this skill's concern to scaffold, only worth knowing when telling the user where the login endpoint lives. -
Is a redirect URI known? Needed for the Azure AD app registration in every environment (the Development script and the Staging/Production CI step each take their own). Ask if the request doesn't name a client - don't guess a port/path.
-
Which accounts should be able to sign in? Ask - don't assume. This is the app registration's
--sign-in-audience, and it also changes whatTenantIdmust hold at runtime, sinceAuthExternalMicrosoftRepositoryinterpolates it straight into the token endpoint URL (https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token):Who can sign in --sign-in-audienceRuntime TenantIdOnly users in your own tenant (default, most common) AzureADMyOrgthe real tenant GUID Users in any Azure AD/Entra org AzureADMultipleOrgsliteral organizationsAny org + personal Microsoft accounts AzureADandPersonalMicrosoftAccountliteral commonPersonal Microsoft accounts only PersonalMicrosoftAccountliteral consumersDefault to
AzureADMyOrgif the user has no specific need - it's the least-privilege choice for internal, single-tenant auth. Whatever is chosen, remind the user that the client-side code that starts the sign-in (MSAL.js or equivalent) must be configured with the matching authority, or Azure rejects the sign-in before a code is ever issued - that part lives outside Nano and this skill can't set it.
appsettings.json - Jwt.ExternalLogins.Microsoft
Base appsettings.json, nested under the existing Jwt block:
"ExternalLogins": {
"Microsoft": {
"TenantId": null,
"ClientId": null,
"ClientSecret": null,
"Scopes": [ "openid", "profile", "email", "offline_access" ]
}
}
offline_access is included by default since most apps want refresh support, and leaving it in
place is safe even for logins that don't use it: LogInExternal/LogInExternal<TFlow>'s
IsRefreshable flag is the real, per-login-call gate - Nano discards the external refresh token
server-side whenever a specific login request sets IsRefreshable: false, regardless of what
Scopes requested. Remove offline_access from Scopes only if this app should never support
refresh at all.
The client-side authorize request's own scope parameter must match - include offline_access
there too, unconditionally, the same as here; consent is granted once, at that initial redirect, so
this app's own config alone can't retroactively grant it.
ExternalLogins.Microsoft stays null in every appsettings*.json file, including
appsettings.Development.json. Unlike the shared JWT Development key pair (a throwaway value
every developer can share, so it's worth hardcoding into the tracked Development file), a Microsoft
app registration's TenantId/ClientId/ClientSecret are tied to whatever Entra ID app
registration each individual developer creates for themselves - there's nothing shared to pre-seed,
and nothing that belongs in a tracked config file at all. Per this app's own .docker/.env
convention (see AGENTS.md's Local Development docker-compose section), real per-developer secrets
go into .docker/.env, gitignored, not into appsettings.Development.json - the Local Development
script below produces exactly the three values that file needs. No Graph API permission is needed
beyond the default - openid/profile/email/offline_access only affect what lands in the
id_token and whether a refresh_token is issued alongside it, not access to any resource.
appsettings.Staging.json/appsettings.Production.json - nothing. Per the Kubernetes section
below, TenantId/ClientId/ClientSecret are injected as environment variables from a Kubernetes
secret, the same way Jwt.PublicKey/PrivateKey are - not present in any config file for those
environments.
.docker/.env - placeholder keys
Always add the three empty keys to this app's own .docker/.env in the same change that adds the
appsettings.json block above - don't leave this for later, and don't skip it just because the
values aren't known yet. This mirrors the null placeholders in appsettings.json: the file
already exists (every app ships one, always present per this app's .docker/.env convention), so
the keys belong there now, empty, the same way TenantId/ClientId/ClientSecret are null in
appsettings.json rather than simply absent.
App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=
App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId=
App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret=
Leave the values empty here - a developer fills them in from the Local Development script's output
below, once they've run it. Key names are the App:-prefixed double-underscore config path, matching
this app's actual appsettings.json nesting under App and the same names the Kubernetes
secretKeyRef entries in the Kubernetes section below use.
Local Development - one-time app registration script
Unlike Staging/Production, there's no CI to run this automatically - each developer runs it once, by
hand, against their own Azure AD/Entra ID tenant. Don't write this to a file in the app - it's a
one-time, per-developer, throwaway command, not a project artifact anyone needs to keep around or
version. Show it directly in your response instead, styled like the deploy.ps1 scripts under
Nano.Azure/* (env vars assigned as literals at the top, plain az calls below, no existence checks
or if/else branching - this runs once, by a human, not repeatedly by CI):
$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};
az ad app create `
--display-name $env:APP_DISPLAY_NAME `
--sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE `
--web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI;
$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv;
$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset `
--id $env:AUTH_MICROSOFT_CLIENT_ID `
--append `
--display-name ($env:APP_DISPLAY_NAME + "-secret") `
--years 2 `
--query "password" -o tsv;
{service-name}is this app's kebab-caseSERVICE_NAME(the same value the CI workflow already uses), with-app-developmentappended - kept distinct from the CI-managed{service-name}-appregistration shared by Staging/Production, since this one belongs to a single developer's own tenant, not a deployed environment.{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.
AUTH_MICROSOFT_REDIRECT_URIis 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/.envstep above already placed - notappsettings.Development.json:App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId=organizations App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId={ClientId} App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret={ClientSecret}.envis already gitignored and already loaded into the app's own compose service viaenv_file, so nothing else needs wiring for these three keys to take effect.
Staging/Production - self-provisioning, self-rotating CI step
This is the part that runs automatically, on every deploy. Add one workflow-level env var:
env:
AUTH_MICROSOFT_REDIRECT_URI: ${{ vars.AUTH_MICROSOFT_REDIRECT_URI }}
vars, not secrets - it isn't sensitive. Ask the user for its value (the real deployed client's
callback URL) rather than defaulting to a placeholder.
--sign-in-audience and TenantId are each assigned once, as their own $env: variables at the
top of the step, not sourced from a workflow variable and not derived by a runtime switch. The
audience was already decided once, at authoring time, in step 4 above - there's nothing left to
branch on at runtime. Fill in $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE and $env:AUTH_MICROSOFT_TENANT_ID
below with whichever pair was chosen, straight from step 4's table (e.g. "AzureADMyOrg" paired with
$env:AZURE_TENANT_ID, or "AzureADMultipleOrgs" paired with "organizations") - the same two
literals used in the Development script above. $env:AUTH_MICROSOFT_TENANT_ID is always set this
way, even for AzureADMyOrg - the Kubernetes secret and $env:GITHUB_ENV output below always read
$env:AUTH_MICROSOFT_TENANT_ID, never $env:AZURE_TENANT_ID directly, so the two stay
interchangeable regardless of which audience a given app uses.
Add a "Setup App Registration" step, after Build & Push Image and before Kubernetes Deploy
(needs AZURE_TENANT_ID/an authenticated az session from Azure Login, and its output feeds the
Kubernetes step):
- name: Setup App Registration
shell: pwsh
run: |
$env:APP_DISPLAY_NAME = $env:SERVICE_NAME + "-app";
$env:SECRET_DISPLAY_NAME = $env:APP_DISPLAY_NAME + "-secret-" + (Get-Date -Format "yyyyMMddHHmmss");
$env:AUTH_MICROSOFT_SIGNIN_AUDIENCE = {SignInAudienceLiteral};
$env:AUTH_MICROSOFT_TENANT_ID = {TenantIdLiteral};
$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 `
--web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI;
$env:AUTH_MICROSOFT_CLIENT_ID = az ad app list --display-name $env:APP_DISPLAY_NAME --query "[0].appId" -o tsv;
}
else
{
az ad app update `
--id $env:AUTH_MICROSOFT_CLIENT_ID `
--sign-in-audience $env:AUTH_MICROSOFT_SIGNIN_AUDIENCE `
--web-redirect-uris $env:AUTH_MICROSOFT_REDIRECT_URI;
}
$env:AUTH_MICROSOFT_CLIENT_SECRET = az ad app credential reset `
--id $env:AUTH_MICROSOFT_CLIENT_ID `
--append `
--display-name $env:SECRET_DISPLAY_NAME `
--years 2 `
--query "password" -o tsv;
echo "::add-mask::$env:AUTH_MICROSOFT_CLIENT_SECRET";
$staleCredentialIds = az ad app credential list --id $env:AUTH_MICROSOFT_CLIENT_ID --query "sort_by(@, &startDateTime)[:-3].keyId" -o tsv;
foreach ($keyId in ($staleCredentialIds -split "`n" | Where-Object { $_ }))
{
az ad app credential delete `
--id $env:AUTH_MICROSOFT_CLIENT_ID `
--key-id $keyId;
}
echo "AUTH_MICROSOFT_CLIENT_ID=$env:AUTH_MICROSOFT_CLIENT_ID" >> $env:GITHUB_ENV;
echo "AUTH_MICROSOFT_CLIENT_SECRET=$env:AUTH_MICROSOFT_CLIENT_SECRET" >> $env:GITHUB_ENV;
echo "AUTH_MICROSOFT_TENANT_ID=$env:AUTH_MICROSOFT_TENANT_ID" >> $env:GITHUB_ENV;
What this does, and why it's shaped this way:
- Idempotent app registration. Looks the app up by display name first; creates it only if missing, otherwise just keeps its redirect URI/audience in sync.
AUTH_MICROSOFT_SIGNIN_AUDIENCEandAUTH_MICROSOFT_TENANT_IDare each literals filled in once, at authoring time, not computed at runtime. OnlyAzureADMyOrguses the real tenant GUID ($env:AZURE_TENANT_ID, the workflow's own tenant) forAUTH_MICROSOFT_TENANT_ID- the other three audiences need the literalorganizations/common/consumersstring instead, per the table in "Before making any change, determine" above. Since the audience is decided once per app, not per run, there's no reason to carry it as a workflow variable or re-deriveTenantIdfrom it with a runtime switch - just assign both as$env:variables once, at the top of the step, when applying this skill (and again in the Development script above).- Always reference
$env:AUTH_MICROSOFT_TENANT_IDdownstream, never$env:AZURE_TENANT_IDdirectly - in the Kubernetes secret'sstringDatabelow and in the$env:GITHUB_ENVoutput. Even when the two happen to hold the same value (theAzureADMyOrgcase), keeping every downstream reference pointed atAUTH_MICROSOFT_TENANT_IDmeans the app works identically regardless of which audience it was configured with - nothing downstream needs to know or care which literal was chosen. --append, not a bareaz ad app credential reset. A bare reset atomically replaces every existing secret - any pod still running the previous deployment's env vars would find itsClientSecretinvalid mid-rollout.--appendadds a new one alongside, so the old secret keeps working until those pods cycle out.- Prune to the newest 3 (
sort_by(@, &startDateTime)[:-3]) so the app registration doesn't accumulate secrets forever, while still giving roughly 3 deploys of grace before an older one actually stops working - comfortably outlasts a normal rolling update. Adjust the-3if a different grace period is wanted, but don't drop the pruning step entirely or the app registration grows an unbounded credential list. ::add-mask::immediately after generating the secret. GitHub only auto-masks values sourced fromsecrets.*- this one is never stored as a GitHub secret (see below), so nothing masks it by default unless this line does. Keep it directly after thecredential resetcall, before anything else touches$env:AUTH_MICROSOFT_CLIENT_SECRET.- No
AUTH_MICROSOFT_CLIENT_SECRETGitHub secret, ever. Unlike the JWT keys (generated once, offline, stored as{ENVIRONMENT}_AUTH_JWT_PUBLIC_KEY/_PRIVATE_KEY), this workflow never depends on a value surviving between runs - every run produces and exports its own. Don't introduce one; it would just go stale the next time this step rotates the secret.
Kubernetes
New file, .kubernetes/auth-microsoft-secret.yaml:
apiVersion: v1
kind: Secret
metadata:
name: auth-microsoft-secret
namespace: %KUBERNETES_NAMESPACE%
type: Opaque
stringData:
tenant-id: %AUTH_MICROSOFT_TENANT_ID%
client-id: %AUTH_MICROSOFT_CLIENT_ID%
client-secret: %AUTH_MICROSOFT_CLIENT_SECRET%
Apply it in the Kubernetes Deploy step alongside auth-jwt-secret.yaml, before
deployment.yaml/stateful-set.yaml. Also add
.kubernetes\auth-microsoft-secret.yaml = .kubernetes\auth-microsoft-secret.yaml to {name}.sln's
.kubernetes SolutionItems block (per AGENTS.md's Solution Structure note) - new files under
.kubernetes/ don't show up in Visual Studio's Solution Explorer otherwise.
.kubernetes/deployment.yaml's container env, alongside the JWT key entries:
- name: App__Authentication__Jwt__ExternalLogins__Microsoft__TenantId
valueFrom:
secretKeyRef:
name: auth-microsoft-secret
key: tenant-id
- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientId
valueFrom:
secretKeyRef:
name: auth-microsoft-secret
key: client-id
- name: App__Authentication__Jwt__ExternalLogins__Microsoft__ClientSecret
valueFrom:
secretKeyRef:
name: auth-microsoft-secret
key: client-secret
After making the change
- Show the user every file touched, grouped by concern: appsettings, the
.docker/.envplaceholder keys, the workflow env var + new CI step, the new Kubernetes secret file, thedeployment.yamladditions, and the.slnentry. - Include the Local Development script directly in your response (not written to a file - see
above), with a reminder to fill in their own redirect URI before running it, then add the three
values the script assigns (
$env:AUTH_MICROSOFT_TENANT_ID/CLIENT_ID/CLIENT_SECRET) to this app's own.docker/.envusing theApp__...key names above - notappsettings.Development.json. - Restate that
offline_accesswas included inScopesby default, and that the client-side authorize request's ownscopeparameter must include it too for Microsoft to actually issue arefresh_token- don't let confirming the config change alone read as the whole fix. - Always include the frontend half in your reply, even though this skill only touches the
backend. The config change alone isn't enough to sign anyone in - the frontend has to redirect
the user through Microsoft's own sign-in first. Per AGENTS.md's
#### Authenticationsection (the same authorize-URL shape and PKCE explanation, don't re-derive it), give the user the authorize URL with this app's actualTenantId/ClientId/RedirectUrifilled in (not left as placeholders, once known):
plus a short explanation thathttps://login.microsoftonline.com/{TenantId}/oauth2/v2.0/authorize ?client_id={ClientId} &response_type=code &redirect_uri={RedirectUri} &response_mode=query &scope=openid profile email offline_access &code_challenge={code_challenge} &code_challenge_method=S256 &state={state}code_challengeisn't something to fill in from this app's own config - it's a PKCE value the frontend itself must generate acode_verifierfor, hash (SHA-256, base64url-encoded) intocode_challengefor this URL, and then send the rawcode_verifierback to the login endpoint alongside thecodeMicrosoft returns.
