Skip to content

Commit fedc613

Browse files
consideRatiomanicsminrk
committed
GoogleOAuthenticator.hosted_domain: check against hd, not email domain
Co-authored-by: Simon Li <orpheus+devel@gmail.com> Co-authored-by: Min RK <benjaminrk@gmail.com>
1 parent d7e0756 commit fedc613

File tree

2 files changed

+121
-68
lines changed

2 files changed

+121
-68
lines changed

oauthenticator/google.py

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,27 @@ def _userdata_url_default(self):
111111
help="""
112112
This config has two functions.
113113
114-
1. Restrict sign-in to a list of email domain names, such as
115-
`["mycollege.edu"]` or `["college1.edu", "college2.edu"]`.
116-
2. If a single domain is specified, the username will be stripped to exclude the `@domain` part.
114+
1. Restrict sign-in to users part of Google organizations/workspaces
115+
managing domains, such as `["mycollege.edu"]` or `["college1.edu",
116+
"college2.edu"]`.
117+
2. If a single domain is specified, usernames with that domain will be
118+
stripped to exclude the `@domain` part.
117119
118-
Note that users with email domains in this list must still be allowed
119-
via another config, such as `allow_all`, `allowed_users`, or
120-
`allowed_google_groups`.
120+
Users not restricted by this configuration must still be explicitly
121+
allowed by a configuration intended to allow users, like `allow_all`,
122+
`allowed_users`, or `allowed_google_groups`.
123+
124+
.. warning::
125+
126+
Changing this config either to or from having a single entry is a
127+
disruptive change as the same Google user will get a new username,
128+
either without or with a domain name included.
129+
:::
121130
122-
```{warning} Disruptive config changes
123-
Changing this config either to or from having a single entry is a
124-
disruptive change as the same Google user will get a new username,
125-
either without or with a domain name included.
126-
```
131+
.. versionchanged:: 16.1
132+
133+
Now restricts sign-in based on the hd claim, not the domain in the
134+
user's email.
127135
""",
128136
)
129137

@@ -174,8 +182,19 @@ def user_info_to_username(self, user_info):
174182
user_email = user_info["email"]
175183
user_domain = user_info["domain"] = user_email.split("@")[1].lower()
176184

177-
if len(self.hosted_domain) == 1 and self.hosted_domain[0] == user_domain:
178-
# unambiguous domain, use only base name
185+
# NOTE: This is not an authorization check, it just about username
186+
# derivation. Decoupling hosted_domain from this is considered in
187+
# https://github.com/jupyterhub/oauthenticator/issues/733.
188+
#
189+
# NOTE: This code is written with without knowing for sure if the user
190+
# email's domain could be different from the domain in hd, so we
191+
# assume it could be even though it seems like it can't be. If a
192+
# Google organization/workspace manages users in a "primary
193+
# domain" and a "secondary domain", users with respective email
194+
# domain have their hd field set respectively.
195+
#
196+
if len(self.hosted_domain) == 1 and user_domain == self.hosted_domain[0]:
197+
# strip the domain in this situation
179198
username = username.split("@")[0]
180199

181200
return username
@@ -214,6 +233,28 @@ async def update_auth_model(self, auth_model):
214233

215234
return auth_model
216235

236+
def check_blocked_users(self, username, auth_model):
237+
"""
238+
Overrides `Authenticator.check_blocked_users` to not only block users in
239+
`Authenticator.blocked_users`, but to also enforce
240+
`GoogleOAuthenticator.hosted_domain` if its configured.
241+
242+
When hosted_domain is configured, users are required to be part of
243+
listed Google organizations/workspaces.
244+
245+
Returns False if the user is blocked, otherwise True.
246+
"""
247+
user_info = auth_model["auth_state"][self.user_auth_state_key]
248+
249+
# hd ref: https://developers.google.com/identity/openid-connect/openid-connect#id_token-hd
250+
hd = user_info.get("hd", "")
251+
252+
if self.hosted_domain and hd not in self.hosted_domain:
253+
self.log.warning(f"Blocked {username} with 'hd={hd}' not in hosted_domain")
254+
return False
255+
256+
return super().check_blocked_users(username, auth_model)
257+
217258
async def check_allowed(self, username, auth_model):
218259
"""
219260
Overrides the OAuthenticator.check_allowed to also allow users part of
@@ -236,19 +277,6 @@ async def check_allowed(self, username, auth_model):
236277
self.log.warning(message)
237278
raise HTTPError(403, message)
238279

239-
# NOTE: If hosted_domain is configured as ["a.com", "b.com"], and
240-
# allowed_google_groups is declared as {"a.com": {"a-group"}}, a
241-
# "b.com" user won't be authorized unless allowed in another way.
242-
#
243-
# This means that its not possible to allow all users of a given
244-
# domain if one wants to restrict another.
245-
#
246-
if self.hosted_domain:
247-
if user_domain not in self.hosted_domain:
248-
message = f"Login with domain @{user_domain} is not allowed"
249-
self.log.warning(message)
250-
raise HTTPError(403, message)
251-
252280
if await super().check_allowed(username, auth_model):
253281
return True
254282

oauthenticator/tests/test_google.py

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,23 @@
55
from unittest import mock
66

77
from pytest import fixture, mark, raises
8-
from tornado.web import HTTPError
98
from traitlets.config import Config
109

1110
from ..google import GoogleOAuthenticator
1211
from .mocks import setup_oauth_mock
1312

1413

15-
def user_model(email, username="user1"):
14+
def user_model(email, username="user1", hd=None):
1615
"""Return a user model"""
17-
return {
16+
model = {
1817
'sub': hashlib.md5(email.encode()).hexdigest(),
1918
'email': email,
2019
'custom': username,
21-
'hd': email.split('@')[1],
2220
'verified_email': True,
2321
}
22+
if hd:
23+
model['hd'] = hd
24+
return model
2425

2526

2627
@fixture
@@ -187,37 +188,49 @@ async def test_google(
187188
assert auth_model == None
188189

189190

190-
async def test_hosted_domain_single_entry(google_client):
191+
@mark.parametrize(
192+
"test_variation_id,user_email,user_hd,expect_username,expect_allowed,expect_admin",
193+
[
194+
("01", "user1@ok-hd.orG", "ok-hd.org", "user1", True, True),
195+
("02", "user2@ok-hd.orG", "ok-hd.org", "user2", True, None),
196+
("03", "blocked@ok-hd.org", "ok-hd.org", None, False, None),
197+
("04", "user2@ok-hd.org", "", None, False, None),
198+
("05", "user1@not-ok.org", "", None, False, None),
199+
# Test variation 06 below isn't believed to be possible, but since we
200+
# aren't sure this test clarifies what we expect to happen.
201+
("06", "user1@other.org", "ok-hd.org", "user1@other.org", True, None),
202+
],
203+
)
204+
async def test_hosted_domain_single_entry(
205+
google_client,
206+
test_variation_id,
207+
user_email,
208+
user_hd,
209+
expect_username,
210+
expect_allowed,
211+
expect_admin,
212+
):
191213
"""
192214
Tests that sign in is restricted to the listed domain and that the username
193215
represents the part before the `@domain.com` as expected when hosted_domain
194216
contains a single entry.
195217
"""
196218
c = Config()
197-
c.GoogleOAuthenticator.hosted_domain = ["In-Hosted-Domain.com"]
219+
c.GoogleOAuthenticator.hosted_domain = ["ok-hd.org"]
198220
c.GoogleOAuthenticator.admin_users = {"user1"}
199-
c.GoogleOAuthenticator.allowed_users = {"user2"}
221+
c.GoogleOAuthenticator.allowed_users = {"user2", "blocked", "user1@other.org"}
222+
c.GoogleOAuthenticator.blocked_users = {"blocked"}
200223
authenticator = GoogleOAuthenticator(config=c)
201224

202-
handled_user_model = user_model("user1@iN-hosteD-domaiN.com")
225+
handled_user_model = user_model(user_email, hd=user_hd)
203226
handler = google_client.handler_for_user(handled_user_model)
204227
auth_model = await authenticator.get_authenticated_user(handler, None)
205-
assert auth_model
206-
assert auth_model["name"] == "user1"
207-
assert auth_model["admin"] == True
208-
209-
handled_user_model = user_model("user2@iN-hosteD-domaiN.com")
210-
handler = google_client.handler_for_user(handled_user_model)
211-
auth_model = await authenticator.get_authenticated_user(handler, None)
212-
assert auth_model
213-
assert auth_model["name"] == "user2"
214-
assert auth_model["admin"] == None
215-
216-
handled_user_model = user_model("user1@not-in-hosted-domain.com")
217-
handler = google_client.handler_for_user(handled_user_model)
218-
with raises(HTTPError) as exc:
219-
await authenticator.get_authenticated_user(handler, None)
220-
assert exc.value.status_code == 403
228+
if expect_allowed:
229+
assert auth_model
230+
assert auth_model["name"] == expect_username
231+
assert auth_model["admin"] == expect_admin
232+
else:
233+
assert auth_model == None
221234

222235

223236
@mark.parametrize(
@@ -235,37 +248,49 @@ async def test_check_allowed_no_auth_state(google_client, name, allowed):
235248
assert await authenticator.check_allowed(name, None)
236249

237250

238-
async def test_hosted_domain_multiple_entries(google_client):
251+
@mark.parametrize(
252+
"test_variation_id,user_email,user_hd,expect_username,expect_allowed",
253+
[
254+
("01", "user1@ok-hd1.orG", "ok-hd1.org", "user1@ok-hd1.org", True),
255+
("02", "user2@ok-hd2.orG", "ok-hd2.org", "user2@ok-hd2.org", True),
256+
("03", "blocked@ok-hd1.org", "ok-hd1.org", None, False),
257+
("04", "user3@ok-hd1.org", "", None, False),
258+
("05", "user1@not-ok.org", "", None, False),
259+
# Test variation 06 below isn't believed to be possible, but since we
260+
# aren't sure this test clarifies what we expect to happen.
261+
("06", "user1@other.org", "ok-hd1.org", "user1@other.org", True),
262+
],
263+
)
264+
async def test_hosted_domain_multiple_entries(
265+
google_client,
266+
test_variation_id,
267+
user_email,
268+
user_hd,
269+
expect_username,
270+
expect_allowed,
271+
):
239272
"""
240273
Tests that sign in is restricted to the listed domains and that the username
241274
represents the full email as expected when hosted_domain contains multiple
242275
entries.
243276
"""
244277
c = Config()
245278
c.GoogleOAuthenticator.hosted_domain = [
246-
"In-Hosted-Domain1.com",
247-
"In-Hosted-Domain2.com",
279+
"ok-hd1.org",
280+
"ok-hd2.ORG",
248281
]
282+
c.GoogleOAuthenticator.blocked_users = ["blocked@ok-hd1.org"]
249283
c.GoogleOAuthenticator.allow_all = True
250284
authenticator = GoogleOAuthenticator(config=c)
251285

252-
handled_user_model = user_model("user1@iN-hosteD-domaiN1.com")
286+
handled_user_model = user_model(user_email, hd=user_hd)
253287
handler = google_client.handler_for_user(handled_user_model)
254288
auth_model = await authenticator.get_authenticated_user(handler, None)
255-
assert auth_model
256-
assert auth_model["name"] == "user1@in-hosted-domain1.com"
257-
258-
handled_user_model = user_model("user2@iN-hosteD-domaiN2.com")
259-
handler = google_client.handler_for_user(handled_user_model)
260-
auth_model = await authenticator.get_authenticated_user(handler, None)
261-
assert auth_model
262-
assert auth_model["name"] == "user2@in-hosted-domain2.com"
263-
264-
handled_user_model = user_model("user1@not-in-hosted-domain.com")
265-
handler = google_client.handler_for_user(handled_user_model)
266-
with raises(HTTPError) as exc:
267-
await authenticator.get_authenticated_user(handler, None)
268-
assert exc.value.status_code == 403
289+
if expect_allowed:
290+
assert auth_model
291+
assert auth_model["name"] == expect_username
292+
else:
293+
assert auth_model == None
269294

270295

271296
@mark.parametrize(

0 commit comments

Comments
 (0)