Skip to content

Commit d7a7eb5

Browse files
lalimashardaCopilotCopilot
authored
Add issuer validation check whenever MSAL JS performs OIDC endpoint discovery (#8570)
This pull request adds issuer validation for OpenID Connect (OIDC) discovery in the `@azure/msal-common` package, ensuring that the issuer returned from the OIDC discovery document matches authority set by the application for security and correctness. It also introduces a new error code for issuer validation failures and updates internal metadata and documentation accordingly. **OIDC Issuer Validation Enhancements:** * Added a `validateIssuer` private method to the `Authority` class in `Authority.ts` to enforce issuer validation based on OIDC and Microsoft-specific rules. This method checks that the issuer from the discovery document matches the authority or known Microsoft hosts, including support for regional and CIAM tenant patterns. If validation fails, a `ClientConfigurationError` is thrown. * Integrated the new `validateIssuer` method into the OIDC discovery flow within the `Authority` class to ensure issuer validation is performed after discovery metadata is fetched. **Error Handling and Codes:** * Introduced a new error code `issuerValidationFailed` in `ClientConfigurationErrorCodes` and exported it for use when issuer validation fails. [[1]](diffhunk://#diff-b8eec2047e45982117c70657c616ab76429becdd5a52b4dd168670cce0688352R29) [[2]](diffhunk://#diff-09087b913ebbfa828e5f36b7476a400328e0a7131db84f622cc5f6994759a117L1584-R1585) [[3]](diffhunk://#diff-09087b913ebbfa828e5f36b7476a400328e0a7131db84f622cc5f6994759a117R2905-R2909) **Metadata and Test Updates:** * Added new metadata for the PPE environment in `AuthorityMetadata.ts` to support additional authority hosts. **Documentation and API Review:** * Updated the API review file (`msal-common.api.md`) to reflect the new error code, document the new method, and adjust line references for TSDoc warnings. [[1]](diffhunk://#diff-09087b913ebbfa828e5f36b7476a400328e0a7131db84f622cc5f6994759a117L1584-R1585) [[2]](diffhunk://#diff-09087b913ebbfa828e5f36b7476a400328e0a7131db84f622cc5f6994759a117R2905-R2909) [[3]](diffhunk://#diff-09087b913ebbfa828e5f36b7476a400328e0a7131db84f622cc5f6994759a117L4825-R4837) **Release and Change Tracking:** * Added a change file describing the patch and referencing the related issue and PR for tracking. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lalimasharda <26092202+lalimasharda@users.noreply.github.com>
1 parent 9884a71 commit d7a7eb5

8 files changed

Lines changed: 632 additions & 24 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "Add issuer validation check on OIDC discovery from network [#8570](https://github.com/AzureAD/microsoft-authentication-library-for-js/pull/8570)",
4+
"packageName": "@azure/msal-common",
5+
"email": "lalimasharda@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

docs/errors.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,9 @@ This error occurs when MSAL.js surpasses the allotted storage limit when attempt
268268
### `invalid_request_method_for_EAR`
269269
- The EAR protocol cannot be used with HTTP method `GET`. The `httpMethod` parameter in all requests using `protocolMode: ProtocolMode.EAR` must be either unset or `"POST"`/`HttpMethod.POST`.
270270

271+
### `issuer_validation_failed`
272+
- Issuer returned from OpenID configuration endpoint does not match with the authority configured by the application.
273+
271274
## Interaction required errors
272275

273276
### `no_tokens_found`

lib/msal-common/apiReview/msal-common.api.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,7 +1581,8 @@ declare namespace ClientConfigurationErrorCodes {
15811581
cannotSetOIDCOptions,
15821582
cannotAllowPlatformBroker,
15831583
authorityMismatch,
1584-
invalidRequestMethodForEAR
1584+
invalidRequestMethodForEAR,
1585+
issuerValidationFailed
15851586
}
15861587
}
15871588
export { ClientConfigurationErrorCodes }
@@ -2901,6 +2902,11 @@ function isServerTelemetryEntity(key: string, entity?: object): boolean;
29012902
// @public
29022903
function isSingleTenant(accountEntity: AccountEntity): boolean;
29032904

2905+
// Warning: (ae-missing-release-tag) "issuerValidationFailed" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
2906+
//
2907+
// @public (undocumented)
2908+
const issuerValidationFailed = "issuer_validation_failed";
2909+
29042910
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
29052911
// Warning: (tsdoc-html-tag-missing-greater-than) The HTML tag has invalid syntax: Expecting an attribute or ">" or "/>"
29062912
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
@@ -4822,11 +4828,12 @@ const X_MS_LIB_CAPABILITY_VALUE: string;
48224828
// src/authority/Authority.ts:464:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
48234829
// src/authority/Authority.ts:465:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
48244830
// src/authority/Authority.ts:500:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4825-
// src/authority/Authority.ts:579:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4826-
// src/authority/Authority.ts:653:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4827-
// src/authority/Authority.ts:691:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4828-
// src/authority/Authority.ts:802:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4829-
// src/authority/Authority.ts:1000:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4831+
// src/authority/Authority.ts:582:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4832+
// src/authority/Authority.ts:656:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4833+
// src/authority/Authority.ts:694:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4834+
// src/authority/Authority.ts:805:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4835+
// src/authority/Authority.ts:1003:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
4836+
// src/authority/Authority.ts:1218:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
48304837
// src/authority/AuthorityOptions.ts:25:5 - (ae-forgotten-export) The symbol "CloudInstanceDiscoveryResponse" needs to be exported by the entry point index.d.ts
48314838
// src/cache/CacheManager.ts:355:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
48324839
// src/cache/CacheManager.ts:356:8 - (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen

lib/msal-common/docs/authority.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ The authority URL guides MSAL where to look for the 3 endpoints that are require
2424

2525
> :bulb: Certain OAuth 2.0 grants may skip the authorize endpoint and go directly for the token endpoint, e.g. [OAuth 2.0 Client Credentials Grant](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow)
2626
27+
## Issuer validation
28+
29+
When MSAL retrieves the OpenID configuration document from the network, it validates the `issuer` field returned by the IdP against the configured authority, per the [OpenID Connect Discovery 1.0 spec](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationValidation). This protects against accepting metadata from a malicious or misconfigured service that hosts an OpenID configuration document under an unrelated domain. The issuer is accepted when its scheme and host (and port) match the configured authority, or &mdash; for Microsoft cloud authorities &mdash; when it is HTTPS and its host is a known Microsoft authority host (including regional variants and `{tenant}.ciamlogin.com` patterns).
30+
31+
If the issuer does not satisfy these conditions, MSAL throws a `ClientConfigurationError` with error code `issuer_validation_failed` and the authentication flow is aborted. This validation is applied only to OpenID configuration documents fetched from the network &mdash; cached, hardcoded, and config-supplied metadata are not re-validated.
32+
33+
> Warning: An IdP whose `issuer` does not satisfy the conditions above will fail discovery. If you are using a non-Microsoft OIDC provider whose issuer does not exactly match the authority host you configured, ensure the authority you pass to MSAL has the same scheme and host as the value the IdP returns in its discovery document.
34+
2735
## Authority configuration
2836

2937
In MSAL, authority can be set in 2 locations:

lib/msal-common/src/authority/Authority.ts

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ export class Authority {
427427
}
428428

429429
/**
430-
* Returns metadata entity from cache if it exists, otherwiser returns a new metadata entity built
430+
* Returns metadata entity from cache if it exists, otherwise returns a new metadata entity built
431431
* from the configured canonical authority
432432
* @returns
433433
*/
@@ -547,6 +547,9 @@ export class Authority {
547547
this.correlationId
548548
)();
549549
if (metadata) {
550+
// Validate the issuer returned by the OIDC discovery document.
551+
this.validateIssuer(metadata.issuer);
552+
550553
// If the user prefers to use an azure region replace the global endpoints with regional information.
551554
if (this.authorityOptions.azureRegionConfiguration?.azureRegion) {
552555
metadata = await invokeAsync(
@@ -839,7 +842,7 @@ export class Authority {
839842
metadataEntity: AuthorityMetadataEntity
840843
): Constants.AuthorityMetadataSource | null {
841844
this.logger.verbose(
842-
"Attempting to get cloud discovery metadata from authority configuration",
845+
"Attempting to get cloud discovery metadata from authority configuration",
843846
this.correlationId
844847
);
845848
this.logger.verbosePii(
@@ -1195,6 +1198,190 @@ export class Authority {
11951198
return InstanceDiscoveryMetadataAliases.has(host);
11961199
}
11971200

1201+
/**
1202+
* Validates the `issuer` returned by an OIDC discovery document against
1203+
* this authority, per
1204+
* https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationValidation
1205+
*
1206+
* The issuer is accepted when ANY of the following holds:
1207+
* 1. The issuer scheme + host + port match the authority's (path may
1208+
* differ). Applies to all authorities.
1209+
* 2. The authority is a Microsoft cloud authority (public, sovereign,
1210+
* or CIAM), the issuer is HTTPS, and the issuer host is in the known
1211+
* Microsoft authority host set.
1212+
* 3. Same as (2), but the issuer host is a single-label regional variant
1213+
* of a known Microsoft host (e.g. `westus.login.microsoftonline.com`).
1214+
* 4. Same as (2), but the issuer host matches the CIAM tenant pattern
1215+
* `{tenant}.ciamlogin.com` with an optional `/{tenant}[.onmicrosoft.com][/v2.0]`
1216+
* path.
1217+
*
1218+
* @param issuer The `issuer` value returned in the OIDC discovery document.
1219+
* @throws ClientConfigurationError("issuer_validation_failed") on failure.
1220+
*/
1221+
private validateIssuer(issuer: string): void {
1222+
if (!issuer) {
1223+
throw createClientConfigurationError(
1224+
ClientConfigurationErrorCodes.issuerValidationFailed
1225+
);
1226+
}
1227+
1228+
// Parse with the WHATWG URL API. URL normalizes scheme + host to lowercase per RFC 3986.
1229+
let issuerUrl: URL;
1230+
try {
1231+
issuerUrl = new URL(issuer);
1232+
} catch {
1233+
throw createClientConfigurationError(
1234+
ClientConfigurationErrorCodes.issuerValidationFailed
1235+
);
1236+
}
1237+
const issuerScheme = issuerUrl.protocol;
1238+
const issuerHost = issuerUrl.host;
1239+
const authorityScheme = (
1240+
this.canonicalAuthorityUrlComponents.Protocol || ""
1241+
).toLowerCase();
1242+
const authorityHost = (
1243+
this.canonicalAuthorityUrlComponents.HostNameAndPort || ""
1244+
).toLowerCase();
1245+
1246+
// Rule 1: Same scheme and host
1247+
const matchesAuthorityOrigin = this.matchesAuthorityOrigin(
1248+
issuerScheme,
1249+
issuerHost,
1250+
authorityScheme,
1251+
authorityHost
1252+
);
1253+
1254+
// Rule 2: The issuer host is a well-known Microsoft authority host (HTTPS only)
1255+
const matchesKnownMicrosoftHost =
1256+
issuerScheme === "https:" &&
1257+
this.isAliasOfKnownMicrosoftAuthority(issuerHost);
1258+
1259+
/*
1260+
* Rule 3: The issuer host is a regional variant ({region}.{host}) of a well-known host
1261+
* (HTTPS only). E.g. westus2.login.microsoft.com
1262+
*/
1263+
const matchesRegionalMicrosoftHost =
1264+
issuerScheme === "https:" &&
1265+
this.matchesRegionalMicrosoftHost(issuerHost);
1266+
1267+
/*
1268+
* Rule 4: CIAM-specific validation. In a CIAM scenario the issuer is expected to
1269+
* have "{tenant}.ciamlogin.com" as the host, even when using a custom domain.
1270+
*/
1271+
const matchesCiamTenantPattern = this.matchesCiamTenantPattern(
1272+
issuerUrl,
1273+
authorityHost,
1274+
this.canonicalAuthorityUrlComponents.PathSegments
1275+
);
1276+
1277+
// Each rule is an independent boolean; the issuer is valid if ANY rule matches.
1278+
if (
1279+
matchesAuthorityOrigin ||
1280+
matchesKnownMicrosoftHost ||
1281+
matchesRegionalMicrosoftHost ||
1282+
matchesCiamTenantPattern
1283+
) {
1284+
return;
1285+
}
1286+
1287+
// issuer validation fails if none of the above rules are satisfied
1288+
throw createClientConfigurationError(
1289+
ClientConfigurationErrorCodes.issuerValidationFailed
1290+
);
1291+
}
1292+
1293+
/**
1294+
* Rule 1: The issuer scheme + host (and port) match the authority's. Path
1295+
* may differ. Applies to all authorities.
1296+
*/
1297+
private matchesAuthorityOrigin(
1298+
issuerScheme: string,
1299+
issuerHost: string,
1300+
authorityScheme: string,
1301+
authorityHost: string
1302+
): boolean {
1303+
return issuerScheme === authorityScheme && issuerHost === authorityHost;
1304+
}
1305+
1306+
/**
1307+
* Rule 3: The issuer host is a regional variant
1308+
* (`{region}.{host}`) of a known Microsoft authority host.
1309+
* E.g. `westus2.login.microsoft.com`.
1310+
*/
1311+
private matchesRegionalMicrosoftHost(issuerHost: string): boolean {
1312+
const firstDot = issuerHost.indexOf(".");
1313+
if (firstDot > 0 && firstDot < issuerHost.length - 1) {
1314+
const hostWithoutRegion = issuerHost.substring(firstDot + 1);
1315+
return this.isAliasOfKnownMicrosoftAuthority(hostWithoutRegion);
1316+
}
1317+
return false;
1318+
}
1319+
1320+
/**
1321+
* Rule 4: The issuer matches one of the well-known CIAM tenant patterns
1322+
* (`https://{tenant}.ciamlogin.com[/{tenant}[.onmicrosoft.com][/v2.0]]`).
1323+
*
1324+
* The bare tenant name is extracted from the authority's first path segment
1325+
* when available (stripping the `.onmicrosoft.com` suffix that
1326+
* `transformCIAMAuthority` adds), or otherwise from the leftmost label of
1327+
* the authority host (to support CIAM custom domain scenarios).
1328+
*
1329+
* Both `/{tenant}` and `/{tenant}.onmicrosoft.com` path forms are accepted
1330+
* because the OIDC issuer may use either form depending on the authority URL
1331+
* that was used to trigger discovery.
1332+
*/
1333+
private matchesCiamTenantPattern(
1334+
issuerUrl: URL,
1335+
authorityHost: string,
1336+
authorityPathSegments: string[]
1337+
): boolean {
1338+
/*
1339+
* authorityPathSegments[0] is the first path segment of the *authority
1340+
* URL* after transformCIAMAuthority runs (e.g. "contoso.onmicrosoft.com").
1341+
* Additional CIAM issuer path segments such as "/v2.0" are part of the
1342+
* issuer string, not the authority URL's PathSegments.
1343+
*/
1344+
const pathSegment = authorityPathSegments[0];
1345+
1346+
/*
1347+
* Extract the bare tenant name: strip the .onmicrosoft.com suffix when
1348+
* present (introduced by transformCIAMAuthority), or fall back to the
1349+
* first label of the authority hostname for non-transformed/custom-domain
1350+
* CIAM authorities.
1351+
*/
1352+
const tenantName = pathSegment
1353+
? pathSegment.endsWith(Constants.AAD_TENANT_DOMAIN_SUFFIX)
1354+
? pathSegment.slice(
1355+
0,
1356+
-Constants.AAD_TENANT_DOMAIN_SUFFIX.length
1357+
)
1358+
: pathSegment
1359+
: authorityHost.split(".")[0];
1360+
1361+
if (!tenantName) {
1362+
return false;
1363+
}
1364+
1365+
const ciamBaseURL = `https://${tenantName}${Constants.CIAM_AUTH_URL}`;
1366+
const validCiamPatterns: string[] = [
1367+
ciamBaseURL, // https://{tenant}.ciamlogin.com
1368+
`${ciamBaseURL}/${tenantName}`, // https://{tenant}.ciamlogin.com/{tenant}
1369+
`${ciamBaseURL}/${tenantName}/v2.0`, // https://{tenant}.ciamlogin.com/{tenant}/v2.0
1370+
`${ciamBaseURL}/${tenantName}${Constants.AAD_TENANT_DOMAIN_SUFFIX}`, // https://{tenant}.ciamlogin.com/{tenant}.onmicrosoft.com
1371+
`${ciamBaseURL}/${tenantName}${Constants.AAD_TENANT_DOMAIN_SUFFIX}/v2.0`, // https://{tenant}.ciamlogin.com/{tenant}.onmicrosoft.com/v2.0
1372+
];
1373+
1374+
/*
1375+
* Compose the canonical issuer string from URL components and strip any
1376+
* trailing slashes from the path so it can be compared to the pattern set.
1377+
*/
1378+
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "");
1379+
const normalizedIssuer = `${issuerUrl.protocol}//${issuerUrl.host}${issuerPath}`;
1380+
return validCiamPatterns.some(
1381+
(pattern) => pattern === normalizedIssuer
1382+
);
1383+
}
1384+
11981385
/**
11991386
* Checks whether the provided host is that of a public cloud authority
12001387
*

lib/msal-common/src/authority/AuthorityMetadata.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ export const rawMetdataJSON: RawMetadata = {
105105
preferred_cache: "login.sovcloud-identity.sg",
106106
aliases: ["login.sovcloud-identity.sg"],
107107
},
108+
{
109+
preferred_network: "login.windows-ppe.net",
110+
preferred_cache: "login.windows-ppe.net",
111+
aliases: [
112+
"login.windows-ppe.net",
113+
"sts.windows-ppe.net",
114+
"login.microsoft-ppe.com",
115+
],
116+
},
108117
],
109118
},
110119
};

lib/msal-common/src/error/ClientConfigurationErrorCodes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export const cannotSetOIDCOptions = "cannot_set_OIDCOptions";
2626
export const cannotAllowPlatformBroker = "cannot_allow_platform_broker";
2727
export const authorityMismatch = "authority_mismatch";
2828
export const invalidRequestMethodForEAR = "invalid_request_method_for_EAR";
29+
export const issuerValidationFailed = "issuer_validation_failed";

0 commit comments

Comments
 (0)