Skip to content

Commit 5626763

Browse files
committed
Use cryptographically secure randomness for PKCE, state, and nonce generation
1 parent 2de45ae commit 5626763

File tree

3 files changed

+82
-5
lines changed

3 files changed

+82
-5
lines changed

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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
import base64
33
import time
4-
import random
4+
import secrets
55
import string
66
import warnings
77
import hashlib
@@ -238,7 +238,7 @@ def initiate_auth_code_flow(
238238
# Here we just automatically add it. If the caller do not want id_token,
239239
# they should simply go with oauth2.Client.
240240
_scope.append("openid")
241-
nonce = "".join(random.sample(string.ascii_letters, 16))
241+
nonce = secrets.token_urlsafe(16)
242242
flow = super(Client, self).initiate_auth_code_flow(
243243
scope=_scope, nonce=_nonce_hash(nonce), **kwargs)
244244
flow["nonce"] = nonce

tests/test_oidc.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,83 @@
1+
import re
2+
import string
3+
14
from tests import unittest
25

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

682

783
class TestIdToken(unittest.TestCase):

0 commit comments

Comments
 (0)