Repository navigation
Expand file tree
/
Copy pathtest_sso.py
More file actions
517 lines (416 loc) · 21.2 KB
/
Copy pathtest_sso.py
File metadata and controls
517 lines (416 loc) · 21.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
<
8000
div data-line-number="386" class="react-line-number react-code-text" style="padding-right:16px">386387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
#!/usr/bin/env python3
"""Tests for src/auth/sso.py — SSO/OIDC sign-in and auto-provisioning.
This module has zero prior coverage and is high-risk: a bug here can mean
account takeover (linking an SSO identity to the wrong local user) or
unauthorized auto-provisioning (creating accounts for disallowed domains).
Functions covered:
- _str_to_bool
- get_sso_config / is_sso_enabled (env-driven, read live per call)
- is_domain_allowed
- _sanitize_username
- generate_unique_username (DB-backed: collision suffixing)
- create_or_update_sso_user (DB-backed: create / link / dedup / authz)
Run (pytest, isolated temp DB from conftest.py):
HOME=/tmp /tmp/speakr_testvenv/bin/python -m pytest tests/test_sso.py -q
"""
import os
import sys
import uuid
import contextlib
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.app import app, db # noqa: E402
from src.models import User # noqa: E402
from src.auth import sso # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Env vars get_sso_config() reads. We snapshot/restore these around each test
# so the suite stays isolated and tests can set them freely.
_SSO_ENV_KEYS = [
"ENABLE_SSO", "SSO_PROVIDER_NAME", "SSO_CLIENT_ID", "SSO_CLIENT_SECRET",
"SSO_DISCOVERY_URL", "SSO_REDIRECT_URI", "SSO_AUTO_REGISTER",
"SSO_ALLOWED_DOMAINS", "SSO_DEFAULT_USERNAME_CLAIM",
"SSO_DEFAULT_NAME_CLAIM", "SSO_DISABLE_PASSWORD_LOGIN",
"SSO_REQUIRE_VERIFIED_EMAIL",
]
@contextlib.contextmanager
def env(**overrides):
"""Temporarily set/unset SSO env vars; restore exactly on exit.
Pass a value to set it; pass None to ensure the var is unset.
"""
saved = {k: os.environ.get(k) for k in _SSO_ENV_KEYS}
try:
for k, v in overrides.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
yield
finally:
for k, old in saved.items():
if old is None:
os.environ.pop(k, None)
else:
os.environ[k] = old
@pytest.fixture
def app_ctx():
with app.app_context():
8000
yield
@pytest.fixture
def cleanup_users(app_ctx):
"""Track usernames/emails/subjects created in a test and delete them after."""
created = {"usernames": set(), "emails": set(), "subjects": set()}
yield created
q = User.query.filter(
db.or_(
User.username.in_(created["usernames"] or [""]),
User.email.in_(created["emails"] or [""]),
User.sso_subject.in_(created["subjects"] or [""]),
)
)
for u in q.all():
db.session.delete(u)
db.session.commit()
def _mk_user(cleanup, username=None, email=None, sso_subject=None, password="x"):
username = username or ("u_" + uuid.uuid4().hex[:14])
email = email or (uuid.uuid4().hex[:10] + "@example.com")
u = User(username=username, email=email, password=password, sso_subject=sso_subject)
db.session.add(u)
db.session.commit()
cleanup["usernames"].add(username)
cleanup["emails"].add(email)
if sso_subject:
cleanup["subjects"].add(sso_subject)
return u
# ---------------------------------------------------------------------------
# _str_to_bool
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value,expected", [
("true", True), ("True", True), ("TRUE", True),
("false", False), ("0", False), ("1", False), ("yes", False),
("", False), (None, False),
])
def test_str_to_bool(value, expected):
assert sso._str_to_bool(value) is expected
# ---------------------------------------------------------------------------
# get_sso_config / is_sso_enabled — read env live per call
# ---------------------------------------------------------------------------
def test_get_sso_config_reads_env_live_per_call():
with env(SSO_PROVIDER_NAME="Okta", ENABLE_SSO="true"):
cfg1 = sso.get_sso_config()
assert cfg1["provider_name"] == "Okta"
assert cfg1["enabled"] is True
# mutate env after first read; a fresh call must reflect it (no caching)
os.environ["SSO_PROVIDER_NAME"] = "Keycloak"
os.environ["ENABLE_SSO"] = "false"
cfg2 = sso.get_sso_config()
assert cfg2["provider_name"] == "Keycloak"
assert cfg2["enabled"] is False
def test_get_sso_config_defaults():
with env(SSO_PROVIDER_NAME=None, SSO_DEFAULT_USERNAME_CLAIM=None,
SSO_DEFAULT_NAME_CLAIM=None, ENABLE_SSO=None, SSO_AUTO_REGISTER=None):
cfg = sso.get_sso_config()
assert cfg["provider_name"] == "SSO"
assert cfg["username_claim"] == "preferred_username"
assert cfg["name_claim"] == "name"
assert cfg["enabled"] is False
# SSO_AUTO_REGISTER defaults to "true"
assert cfg["auto_register"] is True
def test_is_sso_enabled_requires_all_fields():
full = dict(
ENABLE_SSO="true", SSO_CLIENT_ID="cid", SSO_CLIENT_SECRET="secret",
SSO_DISCOVERY_URL="https://idp/.well-known/openid-configuration",
SSO_REDIRECT_URI="https://app/callback",
)
with env(**full):
assert sso.is_sso_enabled() is True
# Disabled flag → False even when everything else present
with env(**{**full, "ENABLE_SSO": "false"}):
assert sso.is_sso_enabled() is False
# Missing client secret → False
with env(**{**full, "SSO_CLIENT_SECRET": None}):
assert sso.is_sso_enabled() is False
# Missing discovery url → False
with env(**{**full, "SSO_DISCOVERY_URL": None}):
assert sso.is_sso_enabled() is False
# ---------------------------------------------------------------------------
# is_domain_allowed
# ---------------------------------------------------------------------------
def test_is_domain_allowed_none_and_malformed_email():
with env(SSO_ALLOWED_DOMAINS="example.com"):
assert sso.is_domain_allowed(None) is False
assert sso.is_domain_allowed("") is False
# no "@" → not allowed
assert sso.is_domain_allowed("not-an-email") is False
def test_is_domain_allowed_empty_allowlist_allows_all():
# Unset → no restriction
with env(SSO_ALLOWED_DOMAINS=None):
assert sso.is_domain_allowed("anyone@anywhere.com") is True
# Empty string → no restriction
with env(SSO_ALLOWED_DOMAINS=""):
assert sso.is_domain_allowed("anyone@anywhere.com") is True
# Whitespace/commas only → parses to empty allowed list → allow all
with env(SSO_ALLOWED_DOMAINS=" , , "):
assert sso.is_domain_allowed("anyone@anywhere.com") is True
def test_is_domain_allowed_matches_listed_domain_case_insensitively():
with env(SSO_ALLOWED_DOMAINS="Example.com, Corp.io"):
assert sso.is_domain_allowed("alice@example.com") is True
assert sso.is_domain_allowed("BOB@EXAMPLE.COM") is True
assert sso.is_domain_allowed("carol@corp.io") is True
def test_is_domain_allowed_rejects_unlisted_domain():
with env(SSO_ALLOWED_DOMAINS="example.com"):
assert sso.is_domain_allowed("mallory@evil.com") is False
# the listed domain appearing only in the local part must NOT pass
assert sso.is_domain_allowed("example.com@evil.com") is False
def test_is_domain_allowed_subdomain_is_not_implied():
# The code does an exact membership test on the full domain after the last
# "@", so a subdomain of an allowed domain is NOT automatically allowed.
with env(SSO_ALLOWED_DOMAINS="example.com"):
assert sso.is_domain_allowed("alice@eng.example.com") is False
# But listing the subdomain explicitly works.
with env(SSO_ALLOWED_DOMAINS="eng.example.com"):
assert sso.is_domain_allowed("alice@eng.example.com") is True
def test_is_domain_allowed_uses_last_at_for_domain():
# rsplit("@", 1) → an address with two "@" is rejected (len(parts)!=2 only
# when there's no "@"; with two "@", rsplit limits to 1 split so the domain
# is what's after the LAST "@"). Verify that real behavior.
with env(SSO_ALLOWED_DOMAINS="example.com"):
# "a@b@example.com" → domain "example.com" → allowed
assert sso.is_domain_allowed("a@b@example.com") is True
# "a@b@evil.com" → domain "evil.com" → rejected
assert sso.is_domain_allowed("a@b@evil.com") is False
# ---------------------------------------------------------------------------
# _sanitize_username
# ---------------------------------------------------------------------------
def test_sanitize_username_keeps_allowed_chars():
assert sso._sanitize_username("john.doe_1-2") == "john.doe_1-2"
def test_sanitize_username_strips_disallowed_chars():
assert sso._sanitize_username("john@doe.com") == "johndoe.com"
assert sso._sanitize_username("a b c!#$%") == "abc"
# unicode / spaces stripped
assert sso._sanitize_username("Renée Müller") == "ReneMller"
def test_sanitize_username_empty_falls_back_to_user():
assert sso._sanitize_username("") == "user"
assert sso._sanitize_username(None) == "user"
# all-disallowed → stripped to empty → "user"
assert sso._sanitize_username("!!!@@@###") == "user"
# ---------------------------------------------------------------------------
# generate_unique_username (DB-backed)
# ---------------------------------------------------------------------------
def test_generate_unique_username_no_collision_returns_sanitized_base(cleanup_users):
base = "uniq" + uuid.uuid4().hex[:8]
got = sso.generate_unique_username(base + "@x.com")
assert got == (base + "x.com")[:20]
def test_generate_unique_username_none_falls_back_to_user(cleanup_users):
# If "user" itself is free this returns "user"; if taken it suffixes. Either
# way it must start with the sanitized "user" base.
got = sso.generate_unique_username(None)
assert got == "user" or got.startswith("user")
def test_generate_unique_username_appends_suffix_on_collision(cleanup_users):
base = "coll" + uuid.uuid4().hex[:6] # < 18 chars, no truncation games
# Occupy the base username.
_mk_user(cleanup_users, username=base)
got = sso.generate_unique_username(base)
assert got != base
assert got == f"{base}01"
# Register so the next call must skip to 02.
_mk_user(cleanup_users, username=got)
got2 = sso.generate_unique_username(base)
assert got2 == f"{base}02"
def test_generate_unique_username_result_is_actually_unique(cleanup_users):
base = "x" + uuid.uuid4().hex[:6]
_mk_user(cleanup_users, username=base)
got = sso.generate_unique_username(base)
# Must not collide with an existing row.
assert User.query.filter_by(username=got).first() is None
# ---------------------------------------------------------------------------
# create_or_update_sso_user (DB-backed) — the high-risk surface
# ---------------------------------------------------------------------------
def _track(cleanup, user):
cleanup["usernames"].add(user.username)
cleanup["emails"].add(user.email)
if user.sso_subject:
cleanup["subjects"].add(user.sso_subject)
def test_create_requires_sub(cleanup_users):
with env(SSO_ALLOWED_DOMAINS=None):
with pytest.raises(ValueError):
sso.create_or_update_sso_user({"email": "noone@example.com"})
def test_create_new_user_from_claims(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
email = "new" + uuid.uuid4().hex[:8] + "@example.com"
preferred = "pref" + uuid.uuid4().hex[:6]
with env(SSO_ALLOWED_DOMAINS=None, SSO_PROVIDER_NAME="Keycloak",
SSO_DEFAULT_USERNAME_CLAIM="preferred_username",
SSO_DEFAULT_NAME_CLAIM="name", SSO_AUTO_REGISTER="true"):
user = sso.create_or_update_sso_user({
"sub": sub, "email": email, "email_verified": True,
"preferred_username": preferred, "name": "Alice Example",
})
_track(cleanup_users, user)
assert user.id is not None
assert user.sso_subject == sub
assert user.sso_provider == "Keycloak"
assert user.email == email
assert user.name == "Alice Example"
assert user.username == preferred[:20]
# SSO users have no local password.
assert user.password is None
def test_second_call_same_subject_updates_not_duplicates(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
email = "dup" + uuid.uuid4().hex[:8] + "@example.com"
with env(SSO_ALLOWED_DOMAINS=None, SSO_DEFAULT_NAME_CLAIM="name"):
u1 = sso.create_or_update_sso_user({"sub": sub, "email": email, "email_verified": True, "name": "First"})
_track(cleanup_users, u1)
first_id = u1.id
# Second call, same subject, changed name → updates the same row.
u2 = sso.create_or_update_sso_user({"sub": sub, "email": email, "email_verified": True, "name": "Second"})
_track(cleanup_users, u2)
assert u2.id == first_id
assert u2.name == "Second"
assert User.query.filter_by(sso_subject=sub).count() == 1
def test_existing_email_user_gets_sso_linked(cleanup_users):
# A pre-existing password user with this email should be LINKED to the SSO
# identity rather than having a duplicate account created.
email = "link" + uuid.uuid4().hex[:8] + "@example.com"
existing = _mk_user(cleanup_users, email=email, password="hashed")
existing_id = existing.id
sub = "sub-" + uuid.uuid4().hex
cleanup_users["subjects"].add(sub)
with env(SSO_ALLOWED_DOMAINS=None, SSO_PROVIDER_NAME="Keycloak"):
linked = sso.create_or_update_sso_user({"sub": sub, "email": email, "email_verified": True, "name": "Linked"})
assert linked.id == existing_id
assert linked.sso_subject == sub
assert linked.sso_provider == "Keycloak"
assert User.query.filter_by(email=email).count() == 1
def test_disallowed_domain_is_rejected(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
with env(SSO_ALLOWED_DOMAINS="example.com", SSO_AUTO_REGISTER="true"):
with pytest.raises(PermissionError):
sso.create_or_update_sso_user({
"sub": sub, "email": "attacker@evil.com", "name": "Mallory",
})
# No account should have been provisioned.
assert User.query.filter_by(sso_subject=sub).first() is None
def test_auto_register_disabled_blocks_new_users(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
with env(SSO_ALLOWED_DOMAINS=None, SSO_AUTO_REGISTER="false"):
with pytest.raises(PermissionError):
sso.create_or_update_sso_user({
"sub": sub, "email": "newbie@example.com", "name": "Newbie",
})
assert User.query.filter_by(sso_subject=sub).first() is None
def test_auto_register_disabled_still_allows_known_subject(cleanup_users):
# Existing SSO user (known subject) must still be able to log in even when
# auto-registration is turned off.
sub = "sub-" + uuid.uuid4().hex
email = "known" + uuid.uuid4().hex[:8] + "@example.com"
existing = _mk_user(cleanup_users, email=email, sso_subject=sub, password=None)
with env(SSO_ALLOWED_DOMAINS=None, SSO_AUTO_REGISTER="false", SSO_DEFAULT_NAME_CLAIM="name"):
user = sso.create_or_update_sso_user({"sub": sub, "email": email, "name": "Known"})
assert user.id == existing.id
assert user.name == "Known"
def test_username_claim_is_respected(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
upn = "upn" + uuid.uuid4().hex[:6]
with env(SSO_ALLOWED_DOMAINS=None, SSO_DEFAULT_USERNAME_CLAIM="upn"):
user = sso.create_or_update_sso_user({
"sub": sub, "email": "claimuser@example.com", "email_verified": True,
"upn": upn, "preferred_username": "should-not-be-used",
})
_track(cleanup_users, user)
assert user.username == upn[:20]
def test_username_falls_back_to_email_localpart(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
local = "local" + uuid.uuid4().hex[:6]
with env(SSO_ALLOWED_DOMAINS=None):
# No username claim provided → use email local part.
user = sso.create_or_update_sso_user({"sub": sub, "email": f"{local}@example.com", "email_verified": True})
_track(cleanup_users, user)
assert user.username == local[:20]
def test_no_email_uses_placeholder(cleanup_users):
sub = "sub-" + uuid.uuid4().hex
with env(SSO_ALLOWED_DOMAINS="example.com"):
# No email claim at all → domain check is skipped, placeholder email set.
user = sso.create_or_update_sso_user({"sub": sub, "preferred_username": "noemail" + sub[:6]})
_track(cleanup_users, user)
assert user.email == f"{sub}@placeholder.local"
# ---------------------------------------------------------------------------
# SSO_REQUIRE_VERIFIED_EMAIL (secure by default; opt out for a trusted IdP)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value,expected", [
(True, True), (False, False),
("true", True), ("True", True), ("1", True), ("yes", True),
("false", False), ("no", False), ("", False), (None, False), (0, False),
])
def test_claim_is_truthy(value, expected):
assert sso._claim_is_truthy(value) is expected
def test_require_verified_email_defaults_on_and_refuses_unverified(cleanup_users):
"""Secure default (flag unset = on): an SSO login with an absent/unverified
email_verified claim is refused before it can link to an existing-by-email
account, preventing takeover (GHSA-w4q5-3526-j82j)."""
with env(SSO_ALLOWED_DOMAINS=None, SSO_REQUIRE_VERIFIED_EMAIL=None):
existing = _mk_user(cleanup_users, email="legacy@example.com")
assert existing.sso_subject is None
sub = "sub-" + uuid.uuid4().hex[:10]
with pytest.raises(PermissionError):
sso.create_or_update_sso_user({"sub": sub, "email": "legacy@example.com"})
# The existing account was NOT linked to the unverified SSO identity.
db.session.refresh(existing)
assert existing.sso_subject is None
def test_require_verified_email_on_rejects_unverified_link(cleanup_users):
"""Flag on: linking to an existing account by an UNVERIFIED email is refused
(the account-takeover vector), and the existing account is left untouched."""
with env(SSO_ALLOWED_DOMAINS=None, SSO_REQUIRE_VERIFIED_EMAIL="true"):
existing = _mk_user(cleanup_users, email="victim@example.com")
sub = "sub-" + uuid.uuid4().hex[:10]
for claims in (
{"sub": sub, "email": "victim@example.com"}, # absent
{"sub": sub, "email": "victim@example.com", "email_verified": False},
{"sub": sub, "email": "victim@example.com", "email_verified": "false"},
):
with pytest.raises(PermissionError):
sso.create_or_update_sso_user(claims)
db.session.expire_all()
# The existing account was NOT hijacked.
refreshed = db.session.get(User, existing.id)
assert refreshed.sso_subject is None
def test_require_verified_email_on_allows_verified_link(cleanup_users):
"""Flag on + email_verified true (bool or string): linking proceeds."""
with env(SSO_ALLOWED_DOMAINS=None, SSO_REQUIRE_VERIFIED_EMAIL="true"):
existing = _mk_user(cleanup_users, email="ok@example.com")
sub = "sub-" + uuid.uuid4().hex[:10]
user = sso.create_or_update_sso_user(
{"sub": sub, "email": "ok@example.com", "email_verified": True}
)
_track(cleanup_users, user)
assert user.id == existing.id
assert user.sso_subject == sub
def test_require_verified_email_on_rejects_new_user_with_unverified_email(cleanup_users):
"""Flag on: provisioning a NEW account with an unverified email is refused
too (no account is created)."""
with env(SSO_ALLOWED_DOMAINS=None, SSO_REQUIRE_VERIFIED_EMAIL="true"):
sub = "sub-" + uuid.uuid4().hex[:10]
email = uuid.uuid4().hex[:10] + "@example.com"
with pytest.raises(PermissionError):
sso.create_or_update_sso_user({"sub": sub, "email": email, "email_verified": False})
assert User.query.filter_by(sso_subject=sub).first() is None
def test_require_verified_email_on_does_not_affect_already_linked_subject(cleanup_users):
"""Flag on: a user already linked by subject keeps logging in even if the
IdP omits email_verified (no email trust is involved on that path)."""
with env(SSO_ALLOWED_DOMAINS=None, SSO_REQUIRE_VERIFIED_EMAIL="true"):
sub = "sub-" + uuid.uuid4().hex[:10]
linked = _mk_user(cleanup_users, email="linked@example.com", sso_subject=sub)
user = sso.create_or_update_sso_user({"sub": sub, "email": "linked@example.com"})
_track(cleanup_users, user)
assert user.id == linked.id
def test_require_verified_email_on_allows_no_email_claim(cleanup_users):
"""Flag on: a login with no email claim is unaffected (nothing to verify);
a placeholder email is assigned as before."""
with env(SSO_ALLOWED_DOMAINS=None, SSO_REQUIRE_VERIFIED_EMAIL="true"):
sub = "sub-" + uuid.uuid4().hex[:10]
user = sso.create_or_update_sso_user({"sub": sub, "preferred_username": "noemail" + sub[:6]})
_track(cleanup_users, user)
assert user.email == f"{sub}@placeholder.local"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))