Skip to content

Commit 6ed3d33

Browse files
[DOP-21482] - implement unit tests for KeycloakAuthProvider (#133)
* [DOP-21482] - implement unit tests for KeycloakAuthProvider * [DOP-21482] - move keycloak test configs to fixtures * [DOP-21482] - removed keycloak settings from TestSettings * [DOP-21482] - add test with expired access_token * [DOP-21482] - add cookie comparison
1 parent fad630c commit 6ed3d33

File tree

10 files changed

+410
-18
lines changed

10 files changed

+410
-18
lines changed

.env.docker

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@db:5432/syncm
2020

2121
# TODO: add to KeycloakAuthProvider documentation about creating new realms, add users, etc.
2222
# KEYCLOAK Auth
23-
SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080/
23+
SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080
2424
SYNCMASTER__AUTH__REALM_NAME=manually_created
2525
SYNCMASTER__AUTH__CLIENT_ID=manually_created
2626
SYNCMASTER__AUTH__CLIENT_SECRET=generated_by_keycloak

.env.local

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ export SYNCMASTER__CRYPTO_KEY=UBgPTioFrtH2unlC4XFDiGf5sYfzbdSf_VgiUSaQc94=
1818
# Postgres
1919
export SYNCMASTER__DATABASE__URL=postgresql+asyncpg://syncmaster:changeme@localhost:5432/syncmaster
2020

21-
# Auth
21+
# Keycloack Auth
22+
export SYNCMASTER__AUTH__SERVER_URL=http://keycloak:8080
23+
export SYNCMASTER__AUTH__REALM_NAME=manually_created
24+
export SYNCMASTER__AUTH__CLIENT_ID=manually_created
25+
export SYNCMASTER__AUTH__CLIENT_SECRET=generated_by_keycloak
26+
export SYNCMASTER__AUTH__REDIRECT_URI=http://localhost:8000/auth/callback
27+
export SYNCMASTER__AUTH__SCOPE=email
28+
export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.keycloak_provider.KeycloakAuthProvider
29+
30+
# Dummy Auth
2231
export SYNCMASTER__AUTH__PROVIDER=syncmaster.backend.providers.auth.dummy_provider.DummyAuthProvider
2332
export SYNCMASTER__AUTH__ACCESS_TOKEN__SECRET_KEY=secret
2433

poetry.lock

Lines changed: 32 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ onetl = {extras = ["spark", "s3", "hdfs"], version = "^0.12.0"}
136136
faker = ">=28.4.1,<34.0.0"
137137
coverage = "^7.6.1"
138138
gevent = "^24.2.1"
139+
responses = "*"
139140

140141
[tool.poetry.group.dev.dependencies]
141142
mypy = "^1.11.2"

syncmaster/backend/providers/auth/keycloak_provider.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ async def get_current_user(self, access_token: str, *args, **kwargs) -> Any:
8181
self.redirect_to_auth(request.url.path)
8282

8383
try:
84+
# if user is disabled or blocked in Keycloak after the token is issued, he will
85+
# remain authorized until the token expires (not more than 15 minutes in MTS SSO)
8486
token_info = self.keycloak_openid.decode_token(token=access_token)
8587
except Exception as e:
8688
log.info("Access token is invalid or expired: %s", e)

tests/conftest.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
pytest_plugins = [
3434
"tests.test_unit.test_transfers.transfer_fixtures",
35+
"tests.test_unit.test_auth.auth_fixtures",
3536
"tests.test_unit.test_runs.run_fixtures",
3637
"tests.test_unit.test_connections.connection_fixtures",
3738
"tests.test_unit.test_scheduler.scheduler_fixtures",
@@ -64,9 +65,9 @@ def event_loop():
6465
loop.close()
6566

6667

67-
@pytest.fixture(scope="session")
68-
def settings():
69-
return Settings()
68+
@pytest.fixture(scope="session", params=[{}])
69+
def settings(request: pytest.FixtureRequest) -> Settings:
70+
return Settings.parse_obj(request.param)
7071

7172

7273
@pytest.fixture(scope="session")

tests/test_unit/test_auth/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from tests.test_unit.test_auth.auth_fixtures.keycloak_fixture import (
2+
create_session_cookie,
3+
mock_keycloak_realm,
4+
mock_keycloak_token_refresh,
5+
mock_keycloak_well_known,
6+
rsa_keys,
7+
)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import json
2+
import time
3+
from base64 import b64encode
4+
5+
import pytest
6+
import responses
7+
from cryptography.hazmat.primitives import serialization
8+
from cryptography.hazmat.primitives.asymmetric import rsa
9+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
10+
from itsdangerous import TimestampSigner
11+
from jose import jwt
12+
13+
14+
@pytest.fixture(scope="session")
15+
def rsa_keys():
16+
# create private & public keys to emulate Keycloak signing
17+
private_key = rsa.generate_private_key(
18+
public_exponent=65537,
19+
key_size=2048,
20+
)
21+
private_pem = private_key.private_bytes(
22+
encoding=serialization.Encoding.PEM,
23+
format=serialization.PrivateFormat.PKCS8,
24+
encryption_algorithm=serialization.NoEncryption(),
25+
)
26+
public_key = private_key.public_key()
27+
28+
return {
29+
"private_key": private_key,
30+
"private_pem": private_pem,
31+
"public_key": public_key,
32+
}
33+
34+
35+
def get_public_key_pem(public_key):
36+
public_pem = public_key.public_bytes(
37+
encoding=Encoding.PEM,
38+
format=PublicFormat.SubjectPublicKeyInfo,
39+
)
40+
public_pem_str = public_pem.decode("utf-8")
41+
public_pem_str = public_pem_str.replace("-----BEGIN PUBLIC KEY-----\n", "")
42+
public_pem_str = public_pem_str.replace("-----END PUBLIC KEY-----\n", "")
43+
public_pem_str = public_pem_str.replace("\n", "")
44+
return public_pem_str
45+
46+
47+
@pytest.fixture
48+
def create_session_cookie(rsa_keys, settings):
49+
def _create_session_cookie(user, expire_in_msec=1000) -> str:
50+
private_pem = rsa_keys["private_pem"]
51+
session_secret_key = settings.server.session.secret_key
52+
53+
payload = {
54+
"sub": str(user.id),
55+
"preferred_username": user.username,
56+
"email": user.email,
57+
"given_name": user.first_name,
58+
"middle_name": user.middle_name,
59+
"family_name": user.last_name,
60+
"exp": int(time.time()) + (expire_in_msec / 1000),
61+
}
62+
63+
access_token = jwt.encode(payload, private_pem, algorithm="RS256")
64+
refresh_token = "mock_refresh_token"
65+
66+
session_data = {
67+
"access_token": access_token,
68+
"refresh_token": refresh_token,
69+
}
70+
71+
signer = TimestampSigner(session_secret_key)
72+
json_bytes = json.dumps(session_data).encode("utf-8")
73+
base64_bytes = b64encode(json_bytes)
74+
signed_data = signer.sign(base64_bytes)
75+
session_cookie = signed_data.decode("utf-8")
76+
77+
return session_cookie
78+
79+
return _create_session_cookie
80+
81+
82+
@pytest.fixture
83+
def mock_keycloak_well_known(settings):
84+
server_url = settings.auth.server_url
85+
realm_name = settings.auth.client_id
86+
well_known_url = f"{server_url}/realms/{realm_name}/.well-known/openid-configuration"
87+
88+
responses.add(
89+
responses.GET,
90+
well_known_url,
91+
json={
92+
"authorization_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/auth",
93+
"token_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token",
94+
"userinfo_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/userinfo",
95+
"end_session_endpoint": f"{server_url}/realms/{realm_name}/protocol/openid-connect/logout",
96+
"jwks_uri": f"{server_url}/realms/{realm_name}/protocol/openid-connect/certs",
97+
"issuer": f"{server_url}/realms/{realm_name}",
98+
},
99+
status=200,
100+
content_type="application/json",
101+
)
102+
103+
104+
@pytest.fixture
105+
def mock_keycloak_realm(settings, rsa_keys):
106+
server_url = settings.auth.server_url
107+
realm_name = settings.auth.client_id
108+
realm_url = f"{server_url}/realms/{realm_name}"
109+
public_pem_str = get_public_key_pem(rsa_keys["public_key"])
110+
111+
responses.add(
112+
responses.GET,
113+
realm_url,
114+
json={
115+
"realm": realm_name,
116+
"public_key": public_pem_str,
117+
"token-service": f"{server_url}/realms/{realm_name}/protocol/openid-connect/token",
118+
"account-service": f"{server_url}/realms/{realm_name}/account",
119+
},
120+
status=200,
121+
content_type="application/json",
122+
)
123+
124+
125+
@pytest.fixture
126+
def mock_keycloak_token_refresh(settings, rsa_keys):
127+
server_url = settings.auth.server_url
128+
realm_name = settings.auth.client_id
129+
token_url = f"{server_url}/realms/{realm_name}/protocol/openid-connect/token"
130+
131+
# generate new access and refresh tokens
132+
expires_in = int(time.time()) + 1000
133+
private_pem = rsa_keys["private_pem"]
134+
payload = {
135+
"sub": "mock_user_id",
136+
"preferred_username": "mock_username",
137+
"email": "[email protected]",
138+
"given_name": "Mock",
139+
"middle_name": "User",
140+
"family_name": "Name",
141+
"exp": expires_in,
142+
}
143+
144+
new_access_token = jwt.encode(payload, private_pem, algorithm="RS256")
145+
new_refresh_token = "mock_new_refresh_token"
146+
147+
responses.add(
148+
responses.POST,
149+
token_url,
150+
json={
151+
"access_token": new_access_token,
152+
"refresh_token": new_refresh_token,
153+
"token_type": "bearer",
154+
"expires_in": expires_in,
155+
},
156+
status=200,
157+
content_type="application/json",
158+
)

0 commit comments

Comments
 (0)