Skip to content

Commit b81257f

Browse files
committed
Merge branch 'dev' into release-1.35.0 to release 1.35.1.
This makes instance discovery cloud local on known clouds.
2 parents 4549f9b + 2de45ae commit b81257f

File tree

8 files changed

+527
-46
lines changed

8 files changed

+527
-46
lines changed

RELEASE_GUIDE.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# MSAL Python — Release Guide
2+
3+
This document provides step-by-step instructions for releasing a new version of `msal` to PyPI.
4+
5+
---
6+
7+
## Prerequisites
8+
9+
- You have push access to the [AzureAD/microsoft-authentication-library-for-python](https://github.com/AzureAD/microsoft-authentication-library-for-python) repository.
10+
- The following GitHub repository secrets are configured:
11+
- `TEST_PYPI_API_TOKEN` — API token for [TestPyPI](https://test.pypi.org/)
12+
- `PYPI_API_TOKEN` — API token for [PyPI](https://pypi.org/)
13+
14+
---
15+
16+
## Version Location
17+
18+
The package version is defined in a single file:
19+
20+
```
21+
msal/sku.py → __version__ = "x.y.z"
22+
```
23+
24+
`setup.cfg` reads it dynamically via `version = attr: msal.__version__`, so **no other file needs updating**.
25+
26+
---
27+
28+
## Branch Strategy
29+
30+
```
31+
dev (all development happens here)
32+
33+
│── feature/fix PR → merged into dev
34+
35+
├──► release-1.35.0 (version branch, cut from dev when ready)
36+
│ │
37+
│ ├── TestPyPI publish (automatic on push)
38+
│ │
39+
│ ├── bug found? fix on dev, merge dev → release-1.35.0
40+
│ │ │
41+
│ │ └── TestPyPI re-publish (automatic)
42+
│ │
43+
│ ├── tag 1.35.0 (via GitHub Release) → PyPI publish
44+
│ │
45+
│ │ ── post-release hotfix needed? ──
46+
│ │
47+
│ ├── fix on dev, merge dev → release-1.35.0
48+
│ │ │
49+
│ │ ├── bump sku.py to 1.35.1
50+
│ │ │
51+
│ │ ├── TestPyPI re-publish (automatic)
52+
│ │ │
53+
│ │ └── tag 1.35.1 (via GitHub Release) → PyPI publish
54+
│ │
55+
│ └── (repeat for further patches: 1.35.2, 1.35.3, ...)
56+
57+
├──► release-1.36.0 (next minor version, cut from dev)
58+
│ │
59+
│ ├── TestPyPI publish
60+
│ │
61+
│ ├── tag 1.36.0 → PyPI publish
62+
│ │
63+
│ └── patches: merge dev → bump → tag 1.36.1, 1.36.2, ...
64+
...
65+
```
66+
67+
- **`dev`** — All feature work, bug fixes, and PRs land here.
68+
- **`release-x.y.z`** — Version branch cut from `dev` when ready to release. Used for final validation and TestPyPI testing.
69+
- **Tags** — Created from the version branch via GitHub Releases to trigger production PyPI publish.
70+
71+
---
72+
73+
## Step-by-Step Release Process
74+
75+
### 1. Complete All Work on `dev`
76+
77+
- All features, fixes, and version bumps should be merged into `dev` via PRs.
78+
- Ensure CI passes on `dev`.
79+
- Update the version in `msal/sku.py` before cutting the release branch:
80+
```python
81+
__version__ = "1.35.0"
82+
```
83+
84+
### 2. Create a Version Branch from `dev`
85+
86+
```bash
87+
git checkout dev
88+
git pull origin dev
89+
git checkout -b release-1.35.0
90+
git push origin release-1.35.0
91+
```
92+
93+
This push triggers the CD pipeline:
94+
- CI runs tests (must pass).
95+
- CD publishes to **TestPyPI** automatically.
96+
- Verify at: https://test.pypi.org/project/msal/
97+
98+
### 3. Apply Patches (If Needed)
99+
100+
If bugs are found during validation:
101+
102+
1. Fix the bug on `dev` first (via a PR to `dev`).
103+
2. Merge `dev` into the version branch:
104+
```bash
105+
git checkout release-1.35.0
106+
git merge dev
107+
git push origin release-1.35.0
108+
```
109+
3. This triggers another TestPyPI publish. Bump the version to `1.35.1` if the previous version was already published.
110+
111+
### 4. Create a GitHub Release (Production Publish)
112+
113+
Once the version branch is validated:
114+
115+
1. Go to **GitHub → Releases → Create a new release**.
116+
2. Click **"Choose a tag"** and type the version (e.g., `1.35.0`) — select **"Create new tag on publish"**.
117+
3. Set **Target** to the `release-1.35.0` branch.
118+
4. Set **Release title** to `1.35.0`.
119+
5. Add release notes (changelog, breaking changes, etc.).
120+
6. Click **"Publish release"**.
121+
122+
This creates a tag, which triggers the CD pipeline to publish to **PyPI**.
123+
124+
Verify at: https://pypi.org/project/msal/
125+
126+
### 5. Post-Release
127+
128+
- Verify installation: `pip install msal==1.35.0`
129+
- If the version on `dev` hasn't been bumped yet, open a PR to bump `msal/sku.py` to the next dev version (e.g., `1.36.0`).
130+
131+
---
132+
133+
## Hotfix Releases
134+
135+
For urgent fixes on an already-released version:
136+
137+
1. Fix the issue on `dev` (via PR).
138+
2. Merge `dev` into the existing `release-x.y.z` branch.
139+
3. Update `msal/sku.py` to the patch version (e.g., `1.35.0``1.35.1`).
140+
4. Push the version branch (triggers TestPyPI).
141+
5. Create a GitHub Release with tag `1.35.1` targeting `release-1.35.0`.
142+
143+
---
144+
145+
## How the CI/CD Pipeline Works
146+
147+
| Job | Trigger | Purpose |
148+
|-----|---------|---------|
149+
| **ci** | Every push and labeled PR to `dev` | Runs tests on Python 3.8–3.14 |
150+
| **cb** | After CI passes | Runs benchmarks |
151+
| **cd** | Push to `release-*` branch or tag | Builds and publishes the package |
152+
153+
| Trigger | Target |
154+
|---------|--------|
155+
| Push to `release-*` branch | **TestPyPI** |
156+
| Tag (created via GitHub Release) | **PyPI** (production) |
157+
158+
---
159+
160+
## Quick Reference
161+
162+
```bash
163+
# 1. Ensure dev is ready, version bumped in msal/sku.py
164+
# 2. Cut version branch
165+
git checkout dev && git pull
166+
git checkout -b release-1.35.0
167+
git push origin release-1.35.0
168+
# → TestPyPI publish happens automatically
169+
170+
# 3. If patches needed: fix on dev, then merge into release branch
171+
git checkout release-1.35.0
172+
git merge dev
173+
git push origin release-1.35.0
174+
175+
# 4. Production release: create a GitHub Release
176+
# → GitHub.com → Releases → New release
177+
# → Tag: 1.35.0, Target: release-1.35.0
178+
# → PyPI publish happens automatically
179+
```

msal/application.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111

1212
from .oauth2cli import Client, JwtAssertionCreator
1313
from .oauth2cli.oidc import decode_part
14-
from .authority import Authority, WORLD_WIDE
14+
from .authority import (
15+
Authority,
16+
WORLD_WIDE,
17+
_get_instance_discovery_endpoint,
18+
_get_instance_discovery_host,
19+
)
1520
from .mex import send_request as mex_send_request
1621
from .wstrust_request import send_request as wst_send_request
1722
from .wstrust_response import *
@@ -671,7 +676,7 @@ def __init__(
671676
self._region_detected = None
672677
self.client, self._regional_client = self._build_client(
673678
client_credential, self.authority)
674-
self.authority_groups = None
679+
self.authority_groups = {}
675680
self._telemetry_buffer = {}
676681
self._telemetry_lock = Lock()
677682
_msal_extension_check()
@@ -1304,9 +1309,16 @@ def _find_msal_accounts(self, environment):
13041309
}
13051310
return list(grouped_accounts.values())
13061311

1307-
def _get_instance_metadata(self): # This exists so it can be mocked in unit test
1312+
def _get_instance_metadata(self, instance): # This exists so it can be mocked in unit test
1313+
instance_discovery_host = _get_instance_discovery_host(instance)
13081314
resp = self.http_client.get(
1309-
"https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https://login.microsoftonline.com/common/oauth2/authorize", # TBD: We may extend this to use self._instance_discovery endpoint
1315+
_get_instance_discovery_endpoint(instance),
1316+
params={
1317+
'api-version': '1.1',
1318+
'authorization_endpoint': (
1319+
"https://{}/common/oauth2/authorize".format(instance_discovery_host)
1320+
),
1321+
},
13101322
headers={'Accept': 'application/json'})
13111323
resp.raise_for_status()
13121324
return json.loads(resp.text)['metadata']
@@ -1318,10 +1330,10 @@ def _get_authority_aliases(self, instance):
13181330
# Then it is an ADFS/B2C/known_authority_hosts situation
13191331
# which may not reach the central endpoint, so we skip it.
13201332
return []
1321-
if not self.authority_groups:
1322-
self.authority_groups = [
1323-
set(group['aliases']) for group in self._get_instance_metadata()]
1324-
for group in self.authority_groups:
1333+
if instance not in self.authority_groups:
1334+
self.authority_groups[instance] = [
1335+
set(group['aliases']) for group in self._get_instance_metadata(instance)]
1336+
for group in self.authority_groups[instance]:
13251337
if instance in group:
13261338
return [alias for alias in group if alias != instance]
13271339
return []

msal/authority.py

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,38 +9,28 @@
99
# Endpoints were copied from here
1010
# https://docs.microsoft.com/en-us/azure/active-directory/develop/authentication-national-cloud#azure-ad-authentication-endpoints
1111
AZURE_US_GOVERNMENT = "login.microsoftonline.us"
12-
AZURE_CHINA = "login.chinacloudapi.cn"
12+
DEPRECATED_AZURE_CHINA = "login.chinacloudapi.cn"
1313
AZURE_PUBLIC = "login.microsoftonline.com"
14+
AZURE_GOV_FR = "login.sovcloud-identity.fr"
15+
AZURE_GOV_DE = "login.sovcloud-identity.de"
16+
AZURE_GOV_SG = "login.sovcloud-identity.sg"
1417

1518
WORLD_WIDE = 'login.microsoftonline.com' # There was an alias login.windows.net
16-
WELL_KNOWN_AUTHORITY_HOSTS = set([
19+
WELL_KNOWN_AUTHORITY_HOSTS = frozenset([
1720
WORLD_WIDE,
18-
AZURE_CHINA,
19-
'login-us.microsoftonline.com',
20-
AZURE_US_GOVERNMENT,
21-
])
22-
23-
# Trusted issuer hosts for OIDC issuer validation
24-
# Includes all well-known Microsoft identity provider hosts and national clouds
25-
TRUSTED_ISSUER_HOSTS = frozenset([
26-
# Global/Public cloud
27-
"login.microsoftonline.com",
2821
"login.microsoft.com",
2922
"login.windows.net",
3023
"sts.windows.net",
31-
# China cloud
32-
"login.chinacloudapi.cn",
24+
DEPRECATED_AZURE_CHINA,
3325
"login.partner.microsoftonline.cn",
34-
# Germany cloud (legacy)
35-
"login.microsoftonline.de",
36-
# US Government clouds
37-
"login.microsoftonline.us",
26+
"login.microsoftonline.de", # deprecated
27+
'login-us.microsoftonline.com',
28+
AZURE_US_GOVERNMENT,
3829
"login.usgovcloudapi.net",
39-
"login-us.microsoftonline.com",
40-
"https://login.sovcloud-identity.fr", # AzureBleu
41-
"https://login.sovcloud-identity.de", # AzureDelos
42-
"https://login.sovcloud-identity.sg", # AzureGovSG
43-
])
30+
AZURE_GOV_FR,
31+
AZURE_GOV_DE,
32+
AZURE_GOV_SG,
33+
])
4434

4535
WELL_KNOWN_B2C_HOSTS = [
4636
"b2clogin.com",
@@ -52,6 +42,15 @@
5242
_CIAM_DOMAIN_SUFFIX = ".ciamlogin.com"
5343

5444

45+
def _get_instance_discovery_host(instance):
46+
return instance if instance in WELL_KNOWN_AUTHORITY_HOSTS else WORLD_WIDE
47+
48+
49+
def _get_instance_discovery_endpoint(instance):
50+
return 'https://{}/common/discovery/instance'.format(
51+
_get_instance_discovery_host(instance))
52+
53+
5554
class AuthorityBuilder(object):
5655
def __init__(self, instance, tenant):
5756
"""A helper to save caller from doing string concatenation.
@@ -157,10 +156,8 @@ def _initialize_entra_authority(
157156
) or (len(parts) == 3 and parts[2].lower().startswith("b2c_"))
158157
self._is_known_to_developer = self.is_adfs or self._is_b2c or not validate_authority
159158
is_known_to_microsoft = self.instance in WELL_KNOWN_AUTHORITY_HOSTS
160-
instance_discovery_endpoint = 'https://{}/common/discovery/instance'.format( # Note: This URL seemingly returns V1 endpoint only
161-
WORLD_WIDE # Historically using WORLD_WIDE. Could use self.instance too
162-
# See https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadInstanceDiscovery.cs#L101-L103
163-
# and https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/blob/4.0.0/src/Microsoft.Identity.Client/Instance/AadAuthority.cs#L19-L33
159+
instance_discovery_endpoint = _get_instance_discovery_endpoint( # Note: This URL seemingly returns V1 endpoint only
160+
self.instance
164161
) if instance_discovery in (None, True) else instance_discovery
165162
if instance_discovery_endpoint and not (
166163
is_known_to_microsoft or self._is_known_to_developer):
@@ -172,8 +169,8 @@ def _initialize_entra_authority(
172169
if payload.get("error") == "invalid_instance":
173170
raise ValueError(
174171
"invalid_instance: "
175-
"The authority you provided, %s, is not whitelisted. "
176-
"If it is indeed your legit customized domain name, "
172+
"The authority you provided, %s, is not known. "
173+
"If it is a valid domain name known to you, "
177174
"you can turn off this check by passing in "
178175
"instance_discovery=False"
179176
% authority_url)
@@ -230,7 +227,7 @@ def has_valid_issuer(self):
230227
return False
231228

232229
# Case 2: Issuer is from a trusted Microsoft host - O(1) lookup
233-
if issuer_host in TRUSTED_ISSUER_HOSTS:
230+
if issuer_host in WELL_KNOWN_AUTHORITY_HOSTS:
234231
return True
235232

236233
# Case 3: Regional variant check - O(1) lookup
@@ -240,7 +237,7 @@ def has_valid_issuer(self):
240237
potential_base = issuer_host[dot_index + 1:]
241238
if "." not in issuer_host[:dot_index]:
242239
# 3a: Base host is a trusted Microsoft host
243-
if potential_base in TRUSTED_ISSUER_HOSTS:
240+
if potential_base in WELL_KNOWN_AUTHORITY_HOSTS:
244241
return True
245242
# 3b: Issuer has a region prefix on the authority host
246243
# e.g. issuer=us.someweb.com, authority=someweb.com

tests/http_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,44 @@ def raise_for_status(self):
4040
if self._raw_resp is not None: # Turns out `if requests.response` won't work
4141
# cause it would be True when 200<=status<400
4242
self._raw_resp.raise_for_status()
43+
44+
45+
class RecordingHttpClient(object):
46+
def __init__(self):
47+
self.get_calls = []
48+
self.post_calls = []
49+
self._get_routes = []
50+
self._post_routes = []
51+
52+
def add_get_route(self, matcher, responder):
53+
self._get_routes.append((matcher, responder))
54+
55+
def add_post_route(self, matcher, responder):
56+
self._post_routes.append((matcher, responder))
57+
58+
def get(self, url, params=None, headers=None, **kwargs):
59+
call = {
60+
"url": url,
61+
"params": params,
62+
"headers": headers,
63+
"kwargs": kwargs,
64+
}
65+
self.get_calls.append(call)
66+
for matcher, responder in self._get_routes:
67+
if matcher(call):
68+
return responder(call)
69+
return MinimalResponse(status_code=404, text="")
70+
71+
def post(self, url, params=None, data=None, headers=None, **kwargs):
72+
call = {
73+
"url": url,
74+
"params": params,
75+
"data": data,
76+
"headers": headers,
77+
"kwargs": kwargs,
78+
}
79+
self.post_calls.append(call)
80+
for matcher, responder in self._post_routes:
81+
if matcher(call):
82+
return responder(call)
83+
return MinimalResponse(status_code=404, text="")

0 commit comments

Comments
 (0)