Skip to content

Local AAD auth emulator: loginParameters ignored, userId missing, userDetails empty (preferred_username not used) #997

@skantere-coc

Description

@skantere-coc
  • swa --version2.0.9 (verified latest at time of filing).
  • Yes, I am accessing the CLI from port :4280.
  • Debug logs were collected with --verbose=silly and confirm the behavior described below.

Describe the bug

When using a real Microsoft Entra ID tenant via the azureActiveDirectory identity provider (i.e. not the built-in mock auth), the local emulator behaves differently from production Azure Static Web Apps in three ways. All three are in dist/msha/auth/routes/:

  1. loginParameters from staticwebapp.config.json are ignored — the AAD authorize URL is built with a hardcoded scope=openid+profile+email and no prompt, domain_hint, login_hint, or extra scope is forwarded.
  2. clientPrincipal.userId is empty for AAD users — the callback handler reads user["id"] / data.id, but AAD's OIDC /userinfo returns the user id as sub (per OIDC spec). Additionally, userId is not even included in the principal object literal that is returned.
  3. clientPrincipal.userDetails is empty for AAD users whose userinfo response lacks email/login — there is no fallback to preferred_username, which is the standard AAD claim.
    Each one breaks an app that works correctly when deployed.

To Reproduce

  1. Create a project with a staticwebapp.config.json configured for a real Entra ID tenant:
{
  "auth": {
    "identityProviders": {
      "azureActiveDirectory": {
        "registration": {
          "openIdIssuer": "https://login.microsoftonline.com/<tenant>/v2.0",
          "clientIdSettingName": "AZURE_CLIENT_ID",
          "clientSecretSettingName": "AZURE_CLIENT_SECRET"
        },
        "login": {
          "loginParameters": [
            "scope=openid profile email api://<api>/Backend.Access",
            "prompt=select_account"
          ]
        }
      }
    }
  },
  "routes": [{ "route": "/*", "allowedRoles": ["authenticated"] }]
}
  1. Set AZURE_CLIENT_ID / AZURE_CLIENT_SECRET env vars to a valid AAD app registration (Web platform, redirect URI http://localhost:4280/.auth/login/aad/callback).
  2. Run:
swa start http://localhost:5173 --run "npm run dev" --api-location http://localhost:7217 --verbose=silly
  1. Open http://localhost:4280/.auth/login/aad in a fresh browser session.
  2. Inspect the redirect URL to login.microsoftonline.com/.../authorize.
  3. Complete login, then GET http://localhost:4280/.auth/me.

Expected behavior

  1. The authorize URL contains every entry from loginParameters (matching what deployed SWA does), e.g. prompt=select_account, scope=openid profile email api://.../Backend.Access, etc.
  2. clientPrincipal.userId is populated with the AAD sub claim.
  3. clientPrincipal.userDetails is populated even when only preferred_username is present.
    In short, /.auth/me should return the same clientPrincipal shape as deployed SWA so backends consuming x-ms-client-principal behave the same locally and in production.

Actual behavior

  1. The authorize URL is:
https://login.microsoftonline.com/<tenant>/oauth2/v2.0/authorize?response_type=code&client_id=<clientId>&redirect_uri=http://localhost:4280/.auth/login/aad/callback&scope=openid+profile+email&state=<state>

loginParameters are silently dropped.
2. clientPrincipal.userId is missing (the field is omitted because the value resolves to undefined).
3. clientPrincipal.userDetails is empty for AAD users without email/login in userinfo.

Suggested fix (drop-in diff against 2.0.9)

Happy to open a PR against main with these changes plus tests, if maintainers are interested.

diff --git a/dist/msha/auth/routes/auth-login-provider-callback.js b/dist/msha/auth/routes/auth-login-provider-callback.js
@@
-        const userDetails = user["login"] || user["email"] || user?.data?.["username"];
+        const userDetails = user["login"] || user["email"] || user?.data?.["username"] || user["preferred_username"];
         const name = user["name"] || user?.data?.["name"];
         const givenName = user["given_name"];
         const familyName = user["family_name"];
         const picture = user["picture"];
-        const userId = user["id"] || user?.data?.["id"];
+        // AAD's `/oidc/userinfo` returns the user id as `sub`, not `id`.
+        const userId = user["id"] || user?.data?.["id"] || user["sub"];
@@
         return {
             identityProvider: authProvider,
+            userId,
             userDetails,
             claims,
             userRoles: ["authenticated", "anonymous"],
diff --git a/dist/msha/auth/routes/auth-login-provider-custom.js b/dist/msha/auth/routes/auth-login-provider-custom.js
@@
-        case "aad":
+        case "aad": {
             const authorizationEndpoint = await new OpenIdHelper(authFields?.openIdIssuer, authFields?.clientIdSettingName).getAuthorizationEndpoint();
-            location = `${authorizationEndpoint}?response_type=code&client_id=${authFields?.clientIdSettingName}&redirect_uri=${redirectUri}/.auth/login/aad/callback&scope=openid+profile+email&state=${hashedState}`;
+            const aadLoginCfg = customAuth?.identityProviders?.[ENTRAID_FULL_NAME]?.login;
+            const aadLoginParams = Array.isArray(aadLoginCfg?.loginParameters) ? aadLoginCfg.loginParameters : [];
+            let aadScope = "openid profile email";
+            const aadExtra = [];
+            for (const p of aadLoginParams) {
+                if (typeof p !== "string") continue;
+                if (p.startsWith("scope=")) {
+                    aadScope = p.substring("scope=".length);
+                } else {
+                    aadExtra.push(p);
+                }
+            }
+            location = `${authorizationEndpoint}?response_type=code&client_id=${authFields?.clientIdSettingName}&redirect_uri=${redirectUri}/.auth/login/aad/callback&scope=${encodeURIComponent(aadScope)}&state=${hashedState}`;
+            if (aadExtra.length) {
+                location += "&" + aadExtra.join("&");
+            }
             break;
+        }

Screenshots

Not applicable — bugs are visible in the redirect URL and in the JSON returned by /.auth/me (text only).

Desktop

  • OS: Windows 11
  • Node.js: v22.22.3
  • @azure/static-web-apps-cli: 2.0.9
  • Azure Functions Core Tools: 4.10.0
  • Browser tested: Edge / Chrome (behavior identical; the bugs are server-side in the emulator)

Additional context

  • The Azure Functions backend in this project is dotnet-isolated on net8.0 and consumes the x-ms-client-principal header. The principal-shape mismatch (bugs 2 and 3) forces the backend to add a fallback parser for userId purely to work around the local emulator — something that should not be necessary if the emulator matches production.
  • All three fixes have been running in production-equivalent local development for several weeks via patch-package against node_modules/@azure/static-web-apps-cli. No regressions observed for other identity providers (GitHub, Twitter, Google, Facebook) — the aad change is scoped to case "aad":, and the userId / userDetails changes are pure additional fallbacks.
  • Related issues (if maintainers want to triage together): Azure/static-web-apps#1123 (federated logout / SSO behavior) — adjacent area but a different code path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions