Skip to content

Commit 6a92f24

Browse files
ashok672CopilotCopilot
authored
Use cryptographically secure randomness for PKCE, state, and nonce generation (#894)
* Use cryptographically secure randomness for PKCE, state, and nonce generation * Update tests/test_oidc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/test_oidc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update msal/oauth2cli/oidc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR review feedback: remove unused imports, simplify loop, add response_mode Agent-Logs-Url: https://github.com/AzureAD/microsoft-authentication-library-for-python/sessions/20f5cbfd-04ca-45f5-8c71-f8df1d00a01e Co-authored-by: ashok672 <83938949+ashok672@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent ecf515a commit 6a92f24

3 files changed

Lines changed: 79 additions & 6 deletions

File tree

msal/oauth2cli/oauth2.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import base64
1414
import sys
1515
import functools
16-
import random
16+
import secrets
1717
import string
1818
import hashlib
1919

@@ -277,8 +277,9 @@ def _scope_set(scope):
277277

278278
def _generate_pkce_code_verifier(length=43):
279279
assert 43 <= length <= 128
280+
alphabet = string.ascii_letters + string.digits + "-._~"
280281
verifier = "".join( # https://tools.ietf.org/html/rfc7636#section-4.1
281-
random.sample(string.ascii_letters + string.digits + "-._~", length))
282+
secrets.choice(alphabet) for _ in range(length))
282283
code_challenge = (
283284
# https://tools.ietf.org/html/rfc7636#section-4.2
284285
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest())
@@ -488,7 +489,7 @@ def initiate_auth_code_flow(
488489
raise ValueError('response_type="token ..." is not allowed')
489490
pkce = _generate_pkce_code_verifier()
490491
flow = { # These data are required by obtain_token_by_auth_code_flow()
491-
"state": state or "".join(random.sample(string.ascii_letters, 16)),
492+
"state": state or secrets.token_urlsafe(16),
492493
"redirect_uri": redirect_uri,
493494
"scope": scope,
494495
}

msal/oauth2cli/oidc.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import json
22
import base64
33
import time
4-
import random
5-
import string
4+
import secrets
65
import warnings
76
import hashlib
87
import logging
@@ -238,7 +237,7 @@ def initiate_auth_code_flow(
238237
# Here we just automatically add it. If the caller do not want id_token,
239238
# they should simply go with oauth2.Client.
240239
_scope.append("openid")
241-
nonce = "".join(random.sample(string.ascii_letters, 16))
240+
nonce = secrets.token_urlsafe(16)
242241
flow = super(Client, self).initiate_auth_code_flow(
243242
scope=_scope, nonce=_nonce_hash(nonce), **kwargs)
244243
flow["nonce"] = nonce

tests/test_oidc.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,80 @@
1+
import string
2+
13
from tests import unittest
24

35
import msal
46
from msal import oauth2cli
7+
from msal.oauth2cli.oauth2 import _generate_pkce_code_verifier
8+
9+
10+
class TestCsprngUsage(unittest.TestCase):
11+
"""Tests that security-critical parameters use cryptographically secure randomness."""
12+
13+
# RFC 7636 §4.1: code_verifier = 43*128unreserved
14+
_PKCE_ALPHABET = set(string.ascii_letters + string.digits + "-._~")
15+
16+
def test_pkce_code_verifier_contains_only_valid_characters(self):
17+
for _ in range(50):
18+
result = _generate_pkce_code_verifier()
19+
self.assertTrue(
20+
set(result["code_verifier"]).issubset(self._PKCE_ALPHABET),
21+
"code_verifier contains invalid characters")
22+
23+
def test_pkce_code_verifier_has_correct_default_length(self):
24+
result = _generate_pkce_code_verifier()
25+
self.assertEqual(len(result["code_verifier"]), 43)
26+
27+
def test_pkce_code_verifier_respects_custom_length(self):
28+
for length in (43, 64, 128):
29+
result = _generate_pkce_code_verifier(length)
30+
self.assertEqual(len(result["code_verifier"]), length)
31+
32+
def test_pkce_code_verifier_can_have_repeated_characters(self):
33+
"""secrets.choice() samples with replacement, unlike the old random.sample()."""
34+
result = _generate_pkce_code_verifier(128)
35+
code_verifier = result["code_verifier"]
36+
self.assertLess(len(set(code_verifier)), len(code_verifier),
37+
"At length 128 with a 66-char alphabet, repeated chars are expected")
38+
39+
def test_pkce_code_verifier_is_not_deterministic(self):
40+
results = {_generate_pkce_code_verifier()["code_verifier"] for _ in range(10)}
41+
self.assertGreater(len(results), 1, "code_verifier should not be deterministic")
42+
43+
def test_oauth2_state_is_url_safe_and_unpredictable(self):
44+
"""State generated by initiate_auth_code_flow should be URL-safe."""
45+
from msal.oauth2cli.oauth2 import Client
46+
client = Client(
47+
{"authorization_endpoint": "https://example.com/auth",
48+
"token_endpoint": "https://example.com/token"},
49+
client_id="test_client")
50+
states = set()
51+
for _ in range(10):
52+
flow = client.initiate_auth_code_flow(
53+
redirect_uri="http://localhost", scope=["openid"],
54+
response_mode="form_post")
55+
state = flow["state"]
56+
self.assertRegex(state, r'^[A-Za-z0-9_-]+$',
57+
"state should be URL-safe")
58+
states.add(state)
59+
self.assertGreater(len(states), 1, "state should not be deterministic")
60+
61+
def test_oidc_nonce_is_url_safe_and_unpredictable(self):
62+
"""Nonce generated by OIDC initiate_auth_code_flow should be URL-safe."""
63+
from msal.oauth2cli.oidc import Client
64+
client = Client(
65+
{"authorization_endpoint": "https://example.com/auth",
66+
"token_endpoint": "https://example.com/token"},
67+
client_id="test_client")
68+
nonces = set()
69+
for _ in range(10):
70+
flow = client.initiate_auth_code_flow(
71+
redirect_uri="http://localhost", scope=["openid"],
72+
response_mode="form_post")
73+
nonce = flow["nonce"]
74+
self.assertRegex(nonce, r'^[A-Za-z0-9_-]+$',
75+
"nonce should be URL-safe")
76+
nonces.add(nonce)
77+
self.assertGreater(len(nonces), 1, "nonce should not be deterministic")
578

679

780
class TestIdToken(unittest.TestCase):

0 commit comments

Comments
 (0)