Skip to content

Commit 1c9ae3a

Browse files
committed
feat: validate bcp47 language tags with a regex
1 parent 4c821b6 commit 1c9ae3a

8 files changed

Lines changed: 89 additions & 5 deletions

File tree

authlib/common/language.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import re
2+
3+
# Structurally validates BCP 47 language tags (RFC 5646).
4+
# Accepts private-use tags (x-...) and standard tags (2-8 alpha + optional subtags).
5+
# Does not validate subtags against the IANA registry.
6+
_LANGUAGE_TAG_RE = re.compile(
7+
r"^(x(-[a-zA-Z0-9]{1,8})+|[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*)$"
8+
)
9+
10+
11+
def is_valid_language_tag(tag):
12+
"""Return True if tag is a structurally valid BCP 47 language tag."""
13+
return isinstance(tag, str) and bool(_LANGUAGE_TAG_RE.match(tag))

authlib/oauth2/rfc8414/models.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from authlib.common.language import is_valid_language_tag
12
from authlib.common.security import is_secure_transport
23
from authlib.common.urls import is_valid_url
34
from authlib.common.urls import urlparse
@@ -208,7 +209,7 @@ def validate_ui_locales_supported(self):
208209
[RFC5646]. If omitted, the set of supported languages and scripts
209210
is unspecified.
210211
"""
211-
validate_array_value(self, "ui_locales_supported")
212+
validate_language_tags_array(self, "ui_locales_supported")
212213

213214
def validate_op_policy_uri(self):
214215
"""OPTIONAL. URL that the authorization server provides to the
@@ -411,6 +412,15 @@ def validate_array_value(metadata, key):
411412
raise ValueError(f'"{key}" MUST be JSON array')
412413

413414

415+
def validate_language_tags_array(metadata, key):
416+
validate_array_value(metadata, key)
417+
values = metadata.get(key)
418+
if values is not None:
419+
for tag in values:
420+
if not is_valid_language_tag(tag):
421+
raise ValueError(f'"{key}" MUST contain BCP 47 language tags')
422+
423+
414424
def validate_boolean_value(metadata, key):
415425
if key not in metadata:
416426
return

authlib/oidc/core/claims.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from joserfc.errors import MissingClaimError
77

88
from authlib.common.encoding import to_bytes
9+
from authlib.common.language import is_valid_language_tag
910
from authlib.oauth2.claims import JWTClaims
1011
from authlib.oauth2.rfc6749.util import scope_to_list
1112

@@ -244,6 +245,12 @@ class UserInfo(dict):
244245
"phone": ["phone_number", "phone_number_verified"],
245246
}
246247

248+
def validate_locale(self):
249+
"""Validate the locale claim is a BCP 47 language tag."""
250+
locale = self.get("locale")
251+
if locale is not None and not is_valid_language_tag(locale):
252+
raise ValueError('"locale" MUST be a BCP 47 language tag')
253+
247254
def filter(self, scope: str):
248255
"""Return a new UserInfo object containing only the claims matching the scope passed in parameter."""
249256
scope = scope_to_list(scope)

authlib/oidc/discovery/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from authlib.oauth2.rfc8414 import AuthorizationServerMetadata
22
from authlib.oauth2.rfc8414.models import validate_array_value
33
from authlib.oauth2.rfc8414.models import validate_boolean_value
4+
from authlib.oauth2.rfc8414.models import validate_language_tags_array
45

56

67
class OpenIDProviderMetadata(AuthorizationServerMetadata):
@@ -236,7 +237,7 @@ def validate_claims_locales_supported(self):
236237
language tag values. Not all languages and scripts are necessarily
237238
supported for all Claim values.
238239
"""
239-
validate_array_value(self, "claims_locales_supported")
240+
validate_language_tags_array(self, "claims_locales_supported")
240241

241242
def validate_claims_parameter_supported(self):
242243
"""OPTIONAL. Boolean value specifying whether the OP supports use of

docs/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Version 1.7.0
2626
- RFC7523 accepts the issuer URL as a valid audience. :issue:`730`
2727
- Fix ``InvalidTokenError`` extra attributes being wrapped instead of passed as
2828
individual key=value pairs in the ``WWW-Authenticate`` header. :pr:`872`
29+
- Validate BCP 47 language tags in ``ui_locales_supported``, ``claims_locales_supported``
30+
and ``UserInfo.locale``. :pr:`873`
2931

3032
Upgrade Guide: :ref:`joserfc_upgrade`.
3133

tests/core/test_oauth2/test_rfc8414.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,14 +260,38 @@ def test_validate_ui_locales_supported():
260260
metadata = AuthorizationServerMetadata()
261261
metadata.validate_ui_locales_supported()
262262

263+
# valid
264+
metadata = AuthorizationServerMetadata({"ui_locales_supported": ["en"]})
265+
metadata.validate_ui_locales_supported()
266+
267+
metadata = AuthorizationServerMetadata(
268+
{"ui_locales_supported": ["en-US", "fr-FR", "zh-Hans-CN"]}
269+
)
270+
metadata.validate_ui_locales_supported()
271+
272+
# private use
273+
metadata = AuthorizationServerMetadata({"ui_locales_supported": ["x-custom"]})
274+
metadata.validate_ui_locales_supported()
275+
263276
# not array
264277
metadata = AuthorizationServerMetadata({"ui_locales_supported": "en"})
265278
with pytest.raises(ValueError, match="JSON array"):
266279
metadata.validate_ui_locales_supported()
267280

268-
# valid
269-
metadata = AuthorizationServerMetadata({"ui_locales_supported": ["en"]})
270-
metadata.validate_ui_locales_supported()
281+
# underscore instead of hyphen
282+
metadata = AuthorizationServerMetadata({"ui_locales_supported": ["fr_FR"]})
283+
with pytest.raises(ValueError, match="BCP 47"):
284+
metadata.validate_ui_locales_supported()
285+
286+
# single char (not private-use)
287+
metadata = AuthorizationServerMetadata({"ui_locales_supported": ["a-US"]})
288+
with pytest.raises(ValueError, match="BCP 47"):
289+
metadata.validate_ui_locales_supported()
290+
291+
# subtag too long
292+
metadata = AuthorizationServerMetadata({"ui_locales_supported": ["toolongsubtag"]})
293+
with pytest.raises(ValueError, match="BCP 47"):
294+
metadata.validate_ui_locales_supported()
271295

272296

273297
def test_validate_op_policy_uri():

tests/core/test_oidc/test_core.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,17 @@ def test_userinfo_getattribute():
170170
assert user.email is None
171171
with pytest.raises(AttributeError):
172172
user.invalid # noqa: B018
173+
174+
175+
def test_userinfo_validate_locale():
176+
UserInfo({"sub": "1"}).validate_locale()
177+
UserInfo({"sub": "1", "locale": "en"}).validate_locale()
178+
UserInfo({"sub": "1", "locale": "en-US"}).validate_locale()
179+
UserInfo({"sub": "1", "locale": "zh-Hans-CN"}).validate_locale()
180+
UserInfo({"sub": "1", "locale": "x-custom"}).validate_locale()
181+
182+
with pytest.raises(ValueError, match="BCP 47"):
183+
UserInfo({"sub": "1", "locale": "fr_FR"}).validate_locale()
184+
185+
with pytest.raises(ValueError, match="BCP 47"):
186+
UserInfo({"sub": "1", "locale": "toolongsubtag"}).validate_locale()

tests/core/test_oidc/test_discovery.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ def test_validate_claims_supported():
119119
def test_validate_claims_locales_supported():
120120
_call_validate_array("claims_locales_supported", ["en-US"])
121121

122+
metadata = OpenIDProviderMetadata(
123+
{"claims_locales_supported": ["en-US", "fr-FR", "zh-Hans-CN"]}
124+
)
125+
metadata.validate_claims_locales_supported()
126+
127+
metadata = OpenIDProviderMetadata({"claims_locales_supported": ["fr_FR"]})
128+
with pytest.raises(ValueError, match="BCP 47"):
129+
metadata.validate_claims_locales_supported()
130+
131+
metadata = OpenIDProviderMetadata({"claims_locales_supported": ["toolongsubtag"]})
132+
with pytest.raises(ValueError, match="BCP 47"):
133+
metadata.validate_claims_locales_supported()
134+
122135

123136
def test_validate_claims_parameter_supported():
124137
_call_validate_boolean("claims_parameter_supported")

0 commit comments

Comments
 (0)