Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/core/utils/openidHelper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { normalizeOpenIdIssuer } from "./openidHelper.js";

describe("normalizeOpenIdIssuer", () => {
describe("Microsoft identity platform (login.microsoftonline.com)", () => {
it("strips legacy /oauth2 segment for v2.0 issuer so discovery resolves (fixes ERR_TOO_MANY_REDIRECTS)", () => {
// Deployed SWA runtime accepts `.../<tenant>/v2.0` but the local CLI's
// `openid-client.discovery()` requires the canonical issuer (no /oauth2).
// Users must be able to use the same `staticwebapp.config.json` in both
// environments, so the CLI normalizes the legacy form before discovery.
const input = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0";
const expected = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe(expected);
});

it("preserves the canonical v2.0 issuer unchanged", () => {
const input = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe(input);
});

it("preserves the issuer regardless of a trailing slash", () => {
const input = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0/";
expect(normalizeOpenIdIssuer(input)).toBe(input);
});

it("handles common-tenant (multi-tenant) endpoint with /oauth2/ prefix", () => {
const input = "https://login.microsoftonline.com/common/oauth2/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe("https://login.microsoftonline.com/common/v2.0");
});

it("handles organizations-tenant endpoint with /oauth2/ prefix", () => {
const input = "https://login.microsoftonline.com/organizations/oauth2/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe("https://login.microsoftonline.com/organizations/v2.0");
});
});

describe("Entra External ID (ciamlogin.com)", () => {
it("preserves ciamlogin.com issuer unchanged (already canonical)", () => {
const input = "https://contoso.ciamlogin.com/contoso.onmicrosoft.com/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe(input);
});

it("strips legacy /oauth2 segment from ciamlogin.com issuer when present", () => {
const input = "https://contoso.ciamlogin.com/contoso.onmicrosoft.com/oauth2/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe("https://contoso.ciamlogin.com/contoso.onmicrosoft.com/v2.0");
});
});

describe("Entra custom URL domains", () => {
it("preserves custom-domain issuer unchanged", () => {
const input = "https://login.contoso.com/00000000-0000-0000-0000-000000000000/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe(input);
});

it("strips legacy /oauth2 segment from custom-domain issuer when present", () => {
const input = "https://login.contoso.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0";
expect(normalizeOpenIdIssuer(input)).toBe("https://login.contoso.com/00000000-0000-0000-0000-000000000000/v2.0");
});
});

describe("edge cases", () => {
it("returns an empty string unchanged (upstream validates separately)", () => {
expect(normalizeOpenIdIssuer("")).toBe("");
});

it("returns a non-matching URL unchanged", () => {
const input = "https://example.com/some/other/issuer";
expect(normalizeOpenIdIssuer(input)).toBe(input);
});

it("does not strip /oauth2 when it is not followed by /v2.0", () => {
// Defensive: we only target the known Microsoft legacy alias form.
const input = "https://example.com/tenant/oauth2/other";
expect(normalizeOpenIdIssuer(input)).toBe(input);
});
});
});
30 changes: 29 additions & 1 deletion src/core/utils/openidHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import * as client from "openid-client";

/**
* Normalize an `openIdIssuer` URL so that `openid-client`'s discovery
* (`<issuer>/.well-known/openid-configuration`) resolves in both local CLI
* and deployed SWA environments.
*
* Background: the Microsoft identity platform v2.0 canonical issuer is
* `https://login.microsoftonline.com/<tenant>/v2.0`. A legacy/alias form,
* `https://login.microsoftonline.com/<tenant>/oauth2/v2.0`, is accepted by
* the deployed SWA runtime but causes the CLI's OIDC discovery to fail
* (ERR_TOO_MANY_REDIRECTS or 404). We strip the trailing `/oauth2` segment
* so that users can keep a single `staticwebapp.config.json` that works
* both locally and when deployed.
*
* The same normalization applies to Entra External ID (`*.ciamlogin.com`)
* and Entra custom URL domains — any `.../<tenant>/oauth2/v2.0` suffix is
* rewritten to `.../<tenant>/v2.0`.
*
* See: https://github.com/Azure/static-web-apps-cli/issues/947
*/
export function normalizeOpenIdIssuer(issuer: string): string {
if (!issuer) {
return issuer;
}
// Rewrite `/oauth2/v2.0` (with optional trailing slash) to `/v2.0` while
// preserving any trailing slash the user provided.
return issuer.replace(/\/oauth2\/v2\.0(\/?)$/, "/v2.0$1");
}

export class OpenIdHelper {
private issuerUrl: URL;
private clientId: string;
Expand All @@ -11,7 +39,7 @@ export class OpenIdHelper {
if (!clientId || clientId.trim() === "") {
throw new Error("Client ID is required");
}
this.issuerUrl = new URL(issuerUrl);
this.issuerUrl = new URL(normalizeOpenIdIssuer(issuerUrl));
this.clientId = clientId;
}

Expand Down
127 changes: 127 additions & 0 deletions src/msha/auth/routes/auth-login-provider-custom.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Tests that `/.auth/login/aad` in local dev — when the real AAD env vars are
// NOT set — falls back to the SWA local auth emulator instead of returning
// `AAD_CLIENT_ID not found in env for 'aad' provider`.
//
// Regression coverage for https://github.com/Azure/static-web-apps-cli/issues/947
// which was broken in 2.0.3 by PR #905.

vi.mock("../../../core/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../core/constants.js")>();
return {
...actual,
SWA_CLI_APP_PROTOCOL: "http",
};
});

// Mock the auth-login-provider module so we can assert emulator delegation
// without depending on the real HTML fixture file.
vi.mock("./auth-login-provider.js", () => {
return {
default: vi.fn(async (context: Context, _request: unknown) => {
context.res = {
status: 200,
headers: { "Content-Type": "text/html" },
body: "<emulator></emulator>",
};
}),
};
});

import { IncomingMessage } from "node:http";
import authLoginProviderEmulator from "./auth-login-provider.js";
import httpTrigger from "./auth-login-provider-custom.js";

describe("auth-login-provider-custom — AAD local emulator fallback (issue #947)", () => {
let context: Context;
let req: IncomingMessage;

const baseCustomAuth = {
identityProviders: {
azureActiveDirectory: {
registration: {
openIdIssuer: "https://login.microsoftonline.com/tenant-id/v2.0",
clientIdSettingName: "AAD_CLIENT_ID",
clientSecretSettingName: "AAD_CLIENT_SECRET",
},
},
},
};

beforeEach(() => {
context = { bindingData: { provider: "aad" } } as unknown as Context;
req = {
url: "/.auth/login/aad",
headers: { host: "localhost:4280" },
} as IncomingMessage;
delete process.env.AAD_CLIENT_ID;
delete process.env.AAD_CLIENT_SECRET;
vi.mocked(authLoginProviderEmulator).mockClear();
});

it("falls back to the local auth emulator when AAD clientId env var is unset", async () => {
// Only secret is set — clientId is missing.
process.env.AAD_CLIENT_SECRET = "test-secret";

await httpTrigger(context, req, baseCustomAuth);

expect(authLoginProviderEmulator).toHaveBeenCalledOnce();
// The custom handler should NOT have returned the 400 error from 2.0.3+.
expect(context.res.status).not.toBe(400);
});

it("falls back to the local auth emulator when AAD clientSecret env var is unset", async () => {
// Only clientId is set — secret is missing.
process.env.AAD_CLIENT_ID = "test-client-id";

await httpTrigger(context, req, baseCustomAuth);

expect(authLoginProviderEmulator).toHaveBeenCalledOnce();
expect(context.res.status).not.toBe(400);
});

it("falls back when both AAD env vars are unset (common local-dev case)", async () => {
await httpTrigger(context, req, baseCustomAuth);

expect(authLoginProviderEmulator).toHaveBeenCalledOnce();
// Before the fix, this would have been: 400 with body
// "AAD_CLIENT_ID not found in env for 'aad' provider".
expect(context.res.status).not.toBe(400);
});

it("does NOT fall back when both AAD env vars are set (real-auth path)", async () => {
process.env.AAD_CLIENT_ID = "test-client-id";
process.env.AAD_CLIENT_SECRET = "test-secret";

// We don't care about discovery here — either a 302 to the issuer or a
// discovery error is fine; we only assert we did NOT delegate to the
// emulator.
try {
await httpTrigger(context, req, baseCustomAuth);
} catch {
// discovery may fail against the fake tenant — that's OK for this assertion.
}

expect(authLoginProviderEmulator).not.toHaveBeenCalled();
});

it("does NOT fall back when the config itself is missing a required field (hard error)", async () => {
// Missing `clientIdSettingName` entirely — this is a user config bug, not
// a local-dev signal, so the handler should surface a 400 as before.
const brokenAuth = {
identityProviders: {
azureActiveDirectory: {
registration: {
openIdIssuer: "https://login.microsoftonline.com/tenant-id/v2.0",
clientSecretSettingName: "AAD_CLIENT_SECRET",
},
},
},
};
process.env.AAD_CLIENT_SECRET = "test-secret";

await httpTrigger(context, req, brokenAuth as unknown as SWAConfigFileAuth);

expect(authLoginProviderEmulator).not.toHaveBeenCalled();
expect(context.res.status).toBe(400);
});
});
42 changes: 42 additions & 0 deletions src/msha/auth/routes/auth-login-provider-custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { CUSTOM_AUTH_REQUIRED_FIELDS, ENTRAID_FULL_NAME, SWA_CLI_APP_PROTOCOL }
import { DEFAULT_CONFIG } from "../../../config.js";
import { encryptAndSign, extractPostLoginRedirectUri, hashStateGuid, newNonceWithExpiration } from "../../../core/utils/auth.js";
import { OpenIdHelper } from "../../../core/utils/openidHelper.js";
import { logger } from "../../../core/utils/logger.js";
import authLoginProviderEmulator from "./auth-login-provider.js";

export const normalizeAuthProvider = function (providerName?: string) {
if (providerName === ENTRAID_FULL_NAME) {
Expand All @@ -13,6 +15,37 @@ export const normalizeAuthProvider = function (providerName?: string) {
return providerName?.toLowerCase() || "";
};

/**
* For the `aad` custom auth provider, when the user's `staticwebapp.config.json`
* references AAD env vars (clientIdSettingName / clientSecretSettingName) but
* those env vars are NOT set, we fall back to the SWA local auth emulator
* instead of hard-failing with a 400.
*
* This restores the pre-2.0.3 behaviour where `/.auth/login/aad` worked in
* local dev without requiring developers to provision a real tenant just to
* run the CLI. See https://github.com/Azure/static-web-apps-cli/issues/947.
*
* Returns `true` iff this is AAD, the config names env vars, and at least one
* of them is unset — i.e. the user is clearly in local-dev mode.
*/
export const shouldFallbackToAadEmulator = function (providerName: string, customAuth?: SWAConfigFileAuth): boolean {
if (providerName !== "aad") {
return false;
}
const registration = customAuth?.identityProviders?.[ENTRAID_FULL_NAME]?.registration;
const clientIdSettingName = registration?.clientIdSettingName;
const clientSecretSettingName = registration?.clientSecretSettingName;
// The config must reference env vars; if either name is missing entirely
// that's a config-level error and should surface as 400 (handled below by
// `checkCustomAuthConfigFields`).
if (!clientIdSettingName || !clientSecretSettingName) {
return false;
}
const clientIdValue = process.env[clientIdSettingName];
const clientSecretValue = process.env[clientSecretSettingName];
return !clientIdValue || !clientSecretValue;
};

export const checkCustomAuthConfigFields = function (context: Context, providerName: string, customAuth?: SWAConfigFileAuth) {
const generateResponse = function (msg: string) {
return {
Expand Down Expand Up @@ -60,6 +93,15 @@ const httpTrigger = async function (context: Context, request: IncomingMessage,
await Promise.resolve();

const providerName: string = normalizeAuthProvider(context.bindingData?.provider);

// Restore pre-2.0.3 behaviour for local dev: if the AAD env vars referenced
// by the user's config aren't set, delegate to the local auth emulator
// instead of hard-failing. See #947.
if (shouldFallbackToAadEmulator(providerName, customAuth)) {
logger.silly(`AAD env vars not set — falling back to the SWA local auth emulator for '/.auth/login/aad'`);
return authLoginProviderEmulator(context, request);
}

const authFields = checkCustomAuthConfigFields(context, providerName, customAuth);
if (!authFields) {
return;
Expand Down
Loading