Skip to content

Commit e01b9bf

Browse files
committed
test(okta): add unit tests for group-role mapping, userinfo and provisioning
Also fix role fallback: unmapped groups should resolve to DEFAULT_ROLE_NAME when group mappings are configured, not use the raw group name as a role.
1 parent afda2a8 commit e01b9bf

2 files changed

Lines changed: 367 additions & 2 deletions

File tree

keep/identitymanager/identity_managers/okta/okta_authverifier.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,13 @@ def _verify_bearer_token(self, token: str = Depends(oauth2_scheme)) -> Authentic
138138
if role_name:
139139
break
140140

141-
# Final fallback: first group as-is, then default role
141+
# Final fallback: use first group as-is only when no mappings are
142+
# configured (raw-group mode), otherwise fall back to the default role.
142143
if not role_name:
143-
role_name = (groups[0] if groups else None) or DEFAULT_ROLE_NAME
144+
if not self.group_mappings and groups:
145+
role_name = groups[0]
146+
else:
147+
role_name = DEFAULT_ROLE_NAME
144148

145149
logger.info(f"Resolved role='{role_name}' for {email}")
146150

tests/test_okta_auth.py

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
"""Tests for Okta auth verifier: group-to-role mapping, userinfo enrichment, and user auto-provisioning."""
2+
import importlib
3+
import sys
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
MOCK_TOKEN = "mock.okta.token"
9+
10+
# ---------------------------------------------------------------------------
11+
# Helpers
12+
# ---------------------------------------------------------------------------
13+
14+
OKTA_ENV = {
15+
"OKTA_ISSUER": "https://okta.example.com",
16+
"OKTA_AUDIENCE": "api://default",
17+
"OKTA_CLIENT_ID": "client123",
18+
"OKTA_CLIENT_SECRET": "secret",
19+
"OKTA_DOMAIN": "okta.example.com",
20+
}
21+
22+
23+
def _make_verifier(extra_env: dict | None = None, monkeypatch=None):
24+
"""Instantiate OktaAuthVerifier with mocked JWKS client."""
25+
env = {**OKTA_ENV, **(extra_env or {})}
26+
for k, v in env.items():
27+
monkeypatch.setenv(k, v)
28+
29+
# Remove cached modules so env vars are picked up fresh
30+
for mod in list(sys.modules.keys()):
31+
if "okta" in mod.lower() or "keep.api.core.config" in mod:
32+
sys.modules.pop(mod, None)
33+
34+
from keep.identitymanager.identity_managers.okta.okta_authverifier import OktaAuthVerifier
35+
36+
verifier = OktaAuthVerifier.__new__(OktaAuthVerifier)
37+
verifier.scopes = []
38+
39+
mock_jwks = MagicMock()
40+
mock_signing_key = MagicMock()
41+
mock_signing_key.key = "mock_key"
42+
mock_jwks.get_signing_key_from_jwt.return_value = mock_signing_key
43+
44+
with patch("jwt.PyJWKClient", return_value=mock_jwks):
45+
verifier.__init__()
46+
47+
return verifier
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# _get_userinfo
52+
# ---------------------------------------------------------------------------
53+
54+
55+
def test_get_userinfo_returns_claims(monkeypatch):
56+
verifier = _make_verifier(monkeypatch=monkeypatch)
57+
verifier.userinfo_url = "https://okta.example.com/v1/userinfo"
58+
59+
mock_resp = MagicMock()
60+
mock_resp.status_code = 200
61+
mock_resp.json.return_value = {
62+
"email": "user@example.com",
63+
"name": "John Doe",
64+
"groups": ["Keep_Admin", "Everyone"],
65+
}
66+
67+
with patch("requests.get", return_value=mock_resp) as mock_get:
68+
result = verifier._get_userinfo(MOCK_TOKEN)
69+
70+
mock_get.assert_called_once_with(
71+
"https://okta.example.com/v1/userinfo",
72+
headers={"Authorization": f"Bearer {MOCK_TOKEN}"},
73+
timeout=5,
74+
)
75+
assert result["name"] == "John Doe"
76+
assert result["groups"] == ["Keep_Admin", "Everyone"]
77+
78+
79+
def test_get_userinfo_returns_empty_on_error(monkeypatch):
80+
verifier = _make_verifier(monkeypatch=monkeypatch)
81+
verifier.userinfo_url = "https://okta.example.com/v1/userinfo"
82+
83+
mock_resp = MagicMock()
84+
mock_resp.status_code = 401
85+
86+
with patch("requests.get", return_value=mock_resp):
87+
result = verifier._get_userinfo(MOCK_TOKEN)
88+
89+
assert result == {}
90+
91+
92+
def test_get_userinfo_returns_empty_when_no_url(monkeypatch):
93+
verifier = _make_verifier(monkeypatch=monkeypatch)
94+
verifier.userinfo_url = None
95+
96+
with patch("requests.get") as mock_get:
97+
result = verifier._get_userinfo(MOCK_TOKEN)
98+
99+
mock_get.assert_not_called()
100+
assert result == {}
101+
102+
103+
def test_get_userinfo_returns_empty_on_exception(monkeypatch):
104+
verifier = _make_verifier(monkeypatch=monkeypatch)
105+
verifier.userinfo_url = "https://okta.example.com/v1/userinfo"
106+
107+
with patch("requests.get", side_effect=ConnectionError("timeout")):
108+
result = verifier._get_userinfo(MOCK_TOKEN)
109+
110+
assert result == {}
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# Group mappings loading
115+
# ---------------------------------------------------------------------------
116+
117+
118+
def test_group_mappings_loaded_from_env(monkeypatch):
119+
verifier = _make_verifier(
120+
extra_env={
121+
"OKTA_ADMIN_GROUPS": "Keep_Admin,Ops_Admin",
122+
"OKTA_NOC_GROUPS": "Keep_User",
123+
"OKTA_WEBHOOK_GROUPS": "Keep_Webhook",
124+
},
125+
monkeypatch=monkeypatch,
126+
)
127+
assert verifier.group_mappings["Keep_Admin"] == "admin"
128+
assert verifier.group_mappings["Ops_Admin"] == "admin"
129+
assert verifier.group_mappings["Keep_User"] == "noc"
130+
assert verifier.group_mappings["Keep_Webhook"] == "webhook"
131+
132+
133+
def test_group_mappings_empty_when_not_configured(monkeypatch):
134+
verifier = _make_verifier(monkeypatch=monkeypatch)
135+
assert verifier.group_mappings == {}
136+
137+
138+
# ---------------------------------------------------------------------------
139+
# Role resolution from groups
140+
# ---------------------------------------------------------------------------
141+
142+
143+
@pytest.mark.parametrize(
144+
"groups, expected_role",
145+
[
146+
(["Keep_Admin", "Keep_User"], "admin"), # admin wins over noc
147+
(["Keep_User"], "noc"),
148+
(["Keep_Webhook"], "webhook"),
149+
(["Keep_User", "Keep_Webhook"], "noc"), # noc wins over webhook
150+
([], "noc"), # default role
151+
(["Unknown_Group"], "noc"), # unmapped group → default
152+
],
153+
)
154+
def test_role_resolution_priority(monkeypatch, groups, expected_role):
155+
verifier = _make_verifier(
156+
extra_env={
157+
"OKTA_ADMIN_GROUPS": "Keep_Admin",
158+
"OKTA_NOC_GROUPS": "Keep_User",
159+
"OKTA_WEBHOOK_GROUPS": "Keep_Webhook",
160+
},
161+
monkeypatch=monkeypatch,
162+
)
163+
164+
jwt_payload = {
165+
"sub": "user@example.com",
166+
"email": "user@example.com",
167+
}
168+
userinfo = {"email": "user@example.com", "name": "Test User", "groups": groups}
169+
170+
with (
171+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
172+
patch("jwt.decode", return_value=jwt_payload),
173+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
174+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=True),
175+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_last_sign_in"),
176+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_role"),
177+
):
178+
mock_key.return_value.key = "mock_key"
179+
entity = verifier._verify_bearer_token(MOCK_TOKEN)
180+
181+
assert entity.role == expected_role
182+
183+
184+
def test_explicit_keep_role_claim_overrides_groups(monkeypatch):
185+
"""keep_role in JWT always takes priority over group mapping."""
186+
verifier = _make_verifier(
187+
extra_env={"OKTA_ADMIN_GROUPS": "Keep_Admin", "OKTA_NOC_GROUPS": "Keep_User"},
188+
monkeypatch=monkeypatch,
189+
)
190+
191+
jwt_payload = {
192+
"sub": "user@example.com",
193+
"email": "user@example.com",
194+
"keep_role": "webhook", # explicit override
195+
}
196+
userinfo = {"email": "user@example.com", "groups": ["Keep_Admin"]}
197+
198+
with (
199+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
200+
patch("jwt.decode", return_value=jwt_payload),
201+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
202+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=True),
203+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_last_sign_in"),
204+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_role"),
205+
):
206+
mock_key.return_value.key = "mock_key"
207+
entity = verifier._verify_bearer_token(MOCK_TOKEN)
208+
209+
assert entity.role == "webhook"
210+
211+
212+
# ---------------------------------------------------------------------------
213+
# User auto-provisioning
214+
# ---------------------------------------------------------------------------
215+
216+
217+
def test_user_created_on_first_login(monkeypatch):
218+
verifier = _make_verifier(monkeypatch=monkeypatch)
219+
220+
jwt_payload = {"sub": "newuser@example.com", "email": "newuser@example.com"}
221+
userinfo = {"email": "newuser@example.com", "name": "New User", "groups": []}
222+
223+
with (
224+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
225+
patch("jwt.decode", return_value=jwt_payload),
226+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
227+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=False) as mock_exists,
228+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.create_user") as mock_create,
229+
):
230+
mock_key.return_value.key = "mock_key"
231+
verifier._verify_bearer_token(MOCK_TOKEN)
232+
233+
mock_exists.assert_called_once()
234+
mock_create.assert_called_once_with(
235+
tenant_id="keep",
236+
username="newuser@example.com",
237+
password="",
238+
role="noc",
239+
)
240+
241+
242+
def test_user_updated_on_subsequent_login(monkeypatch):
243+
verifier = _make_verifier(
244+
extra_env={"OKTA_ADMIN_GROUPS": "Keep_Admin"},
245+
monkeypatch=monkeypatch,
246+
)
247+
248+
jwt_payload = {"sub": "existing@example.com", "email": "existing@example.com"}
249+
userinfo = {"email": "existing@example.com", "name": "Existing User", "groups": ["Keep_Admin"]}
250+
251+
with (
252+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
253+
patch("jwt.decode", return_value=jwt_payload),
254+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
255+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=True),
256+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.create_user") as mock_create,
257+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_last_sign_in") as mock_last_sign_in,
258+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_role") as mock_update_role,
259+
):
260+
mock_key.return_value.key = "mock_key"
261+
verifier._verify_bearer_token(MOCK_TOKEN)
262+
263+
mock_create.assert_not_called()
264+
mock_last_sign_in.assert_called_once()
265+
mock_update_role.assert_called_once_with(tenant_id="keep", username="existing@example.com", role="admin")
266+
267+
268+
def test_auto_create_disabled(monkeypatch):
269+
verifier = _make_verifier(
270+
extra_env={"OKTA_AUTO_CREATE_USER": "false"},
271+
monkeypatch=monkeypatch,
272+
)
273+
274+
jwt_payload = {"sub": "newuser@example.com", "email": "newuser@example.com"}
275+
userinfo = {"email": "newuser@example.com", "groups": []}
276+
277+
with (
278+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
279+
patch("jwt.decode", return_value=jwt_payload),
280+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
281+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=False),
282+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.create_user") as mock_create,
283+
):
284+
mock_key.return_value.key = "mock_key"
285+
verifier._verify_bearer_token(MOCK_TOKEN)
286+
287+
mock_create.assert_not_called()
288+
289+
290+
# ---------------------------------------------------------------------------
291+
# Name / email extraction
292+
# ---------------------------------------------------------------------------
293+
294+
295+
def test_name_comes_from_userinfo(monkeypatch):
296+
verifier = _make_verifier(monkeypatch=monkeypatch)
297+
298+
jwt_payload = {"sub": "user@example.com", "email": "user@example.com"}
299+
userinfo = {"email": "user@example.com", "name": "Jane Doe", "groups": []}
300+
301+
with (
302+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
303+
patch("jwt.decode", return_value=jwt_payload),
304+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
305+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=True),
306+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_last_sign_in"),
307+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_role"),
308+
):
309+
mock_key.return_value.key = "mock_key"
310+
entity = verifier._verify_bearer_token(MOCK_TOKEN)
311+
312+
assert entity.name == "Jane Doe"
313+
314+
315+
def test_name_falls_back_to_email_when_absent(monkeypatch):
316+
verifier = _make_verifier(monkeypatch=monkeypatch)
317+
318+
jwt_payload = {"sub": "user@example.com", "email": "user@example.com"}
319+
userinfo = {"email": "user@example.com", "groups": []} # no name
320+
321+
with (
322+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
323+
patch("jwt.decode", return_value=jwt_payload),
324+
patch.object(verifier, "_get_userinfo", return_value=userinfo),
325+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.user_exists", return_value=True),
326+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_last_sign_in"),
327+
patch("keep.identitymanager.identity_managers.okta.okta_authverifier.update_user_role"),
328+
):
329+
mock_key.return_value.key = "mock_key"
330+
entity = verifier._verify_bearer_token(MOCK_TOKEN)
331+
332+
assert entity.name == "user@example.com"
333+
334+
335+
# ---------------------------------------------------------------------------
336+
# Token errors
337+
# ---------------------------------------------------------------------------
338+
339+
340+
def test_missing_token_raises_401(monkeypatch):
341+
from fastapi import HTTPException
342+
343+
verifier = _make_verifier(monkeypatch=monkeypatch)
344+
with pytest.raises(HTTPException) as exc_info:
345+
verifier._verify_bearer_token("")
346+
assert exc_info.value.status_code == 401
347+
348+
349+
def test_expired_token_raises_401(monkeypatch):
350+
import jwt as pyjwt
351+
from fastapi import HTTPException
352+
353+
verifier = _make_verifier(monkeypatch=monkeypatch)
354+
with (
355+
patch.object(verifier.jwks_client, "get_signing_key_from_jwt") as mock_key,
356+
patch("jwt.decode", side_effect=pyjwt.ExpiredSignatureError),
357+
):
358+
mock_key.return_value.key = "mock_key"
359+
with pytest.raises(HTTPException) as exc_info:
360+
verifier._verify_bearer_token(MOCK_TOKEN)
361+
assert exc_info.value.status_code == 401

0 commit comments

Comments
 (0)