Files
connpy/connpy/tests/test_multiuser_grpc_auth.py
T
fluzzi32 744e730672 feat(auth,cli): add SSO/OIDC authentication and provider management
- Introduce `conn sso` CLI suite for managing Identity Providers (IdP).
- Implement `login_sso` and `get_sso_providers` in gRPC AuthService.
- Add auto-provisioning for users logging in via SSO.
- Support JWT validation via shared secrets (HS256) or JWKS (RS256).
- Add domain restriction (`allowed_domains`) and env-var secret resolution.
- Increase JWT session expiration from 8 to 12 hours.
- Add shell autocompletion for SSO commands and configured providers.
- Bump version to 6.0.3.
2026-06-04 18:33:26 -03:00

361 lines
14 KiB
Python

import os
import pytest
import grpc
from concurrent import futures
from google.protobuf.empty_pb2 import Empty
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
from connpy.configfile import configfile
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing gRPC auth."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
# Initialize basic config file inside it
from connpy.configfile import configfile
conf_file = os.path.join(str(config_dir), "config.yaml")
configfile(conf=conf_file)
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry."""
return UserRegistry(str(test_config_dir))
@pytest.fixture
def auth_grpc_server(test_config_dir, registry):
"""Starts an authenticated local gRPC server for integration testing."""
srv = grpc.server(
futures.ThreadPoolExecutor(max_workers=5),
interceptors=[server.AuthInterceptor(registry)]
)
fallback_provider = ServiceProvider(configfile(conf=os.path.join(str(test_config_dir), "config.yaml")), mode="local")
# Register services
connpy_pb2_grpc.add_NodeServiceServicer_to_server(server.NodeServicer(fallback_provider, registry=registry), srv)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(server.AuthServicer(registry), srv)
port = srv.add_insecure_port('127.0.0.1:0')
srv.start()
yield f"127.0.0.1:{port}"
srv.stop(0)
@pytest.fixture
def channel(auth_grpc_server):
with grpc.insecure_channel(auth_grpc_server) as channel:
yield channel
class TestGRPCAuthentication:
def test_backward_compatibility_no_users(self, channel, registry):
"""Verifies that if no users are registered, gRPC calls proceed without authentication."""
assert registry.has_users() is False
# Calling NodeService list_nodes should succeed without any authorization metadata
stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
res = stub.list_nodes(req)
assert res is not None
def test_login_and_authenticated_calls(self, channel, registry):
"""Tests user creation, login to retrieve JWT, and using JWT to access protected endpoints."""
username = "alice"
password = "alicepassword"
# 1. Register a user in the registry
registry.user_service.create_user(username, password)
assert registry.has_users() is True
# 2. Try unauthenticated call - must fail with UNAUTHENTICATED
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
with pytest.raises(grpc.RpcError) as exc:
node_stub.list_nodes(req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Authorization token is missing" in exc.value.details()
# 3. Call login endpoint (open method) - must succeed
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username=username, password=password)
login_res = auth_stub.login(login_req)
assert login_res.username == username
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 4. Make authenticated call using Bearer token - must succeed
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_login_invalid_credentials(self, channel, registry):
"""Verifies login fails and returns UNAUTHENTICATED for incorrect credentials."""
registry.user_service.create_user("bob", "bobpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username="bob", password="wrongpassword")
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Invalid username or password" in exc.value.details()
def test_change_password(self, channel, registry):
"""Tests changing password via gRPC and verifying old password no longer works."""
username = "charlie"
registry.user_service.create_user(username, "oldpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
# 1. Login with old password to get token
login_res = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
token = login_res.token
# 2. Change password via gRPC using the token
metadata = [("authorization", f"Bearer {token}")]
change_req = connpy_pb2.ChangePasswordRequest(old_password="oldpass", new_password="newpass")
auth_stub.change_password(change_req, metadata=metadata)
# 3. Logging in with old password must fail
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
# 4. Logging in with new password must succeed
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
assert login_res_new.token is not None
def test_sso_login_success_and_auto_provision(self, channel, registry):
"""Tests that a valid SSO token successfully logs the user in and auto-provisions their account."""
import jwt
# 1. Setup SSO configuration in the registry's shared config
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "preferred_username",
"algorithms": ["HS256"]
}
}
}
# 2. Check that the user 'ssoalice' does not exist yet
assert not any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
# 3. Generate a valid SSO token signed with Authelia's secret
sso_token = jwt.encode(
{"preferred_username": "ssoalice"},
"sso-shared-secret",
algorithm="HS256"
)
# 4. Call login_sso
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="ssoalice",
id_token=sso_token,
provider="authelia"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "ssoalice"
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 5. Verify user 'ssoalice' was auto-created/provisioned
assert any(u["username"] == "ssoalice" for u in registry.user_service.list_users())
# 6. Make an authenticated call to NodeService list_nodes with the returned token
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_sso_login_invalid_signature(self, channel, registry):
"""Verifies that an SSO token with an invalid signature fails with UNAUTHENTICATED."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
# Token signed with a WRONG key
wrong_token = jwt.encode({"sub": "bob"}, "wrong-secret", algorithm="HS256")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="bob",
id_token=wrong_token,
provider="authelia"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "SSO Token validation failed" in exc.value.details()
def test_sso_login_mismatched_username(self, channel, registry):
"""Verifies that if the requested username doesn't match the token claim, it fails."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-shared-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
token = jwt.encode({"sub": "charlie"}, "sso-shared-secret", algorithm="HS256")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="different_user",
id_token=token,
provider="authelia"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Mismatched username" in exc.value.details()
def test_sso_login_allowed_domains_success(self, channel, registry):
"""Verifies that SSO login succeeds if email matches allowed_domains."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com", "other.org"]
}
}
}
token = jwt.encode(
{"sub": "john", "email": "john@yyy.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "john"
def test_sso_login_allowed_domains_failed(self, channel, registry):
"""Verifies that SSO login fails if email does not match allowed_domains."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com"]
}
}
}
token = jwt.encode(
{"sub": "john", "email": "john@attacker.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login_sso(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "SSO user domain 'attacker.com' not allowed" in exc.value.details()
def test_sso_login_allowed_domains_fallback_to_username(self, channel, registry):
"""Verifies allowed_domains validation falls back to username claim if email is not present."""
import jwt
registry._shared_config.config["sso"] = {
"providers": {
"google": {
"secret": "google-secret",
"username_claim": "sub",
"algorithms": ["HS256"],
"allowed_domains": ["yyy.com"]
}
}
}
token = jwt.encode(
{"sub": "john@yyy.com"},
"google-secret",
algorithm="HS256"
)
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginSSORequest(
username="john",
id_token=token,
provider="google"
)
login_res = auth_stub.login_sso(login_req)
assert login_res.username == "john"
def test_login_and_login_sso_expiration_time(self, channel, registry):
"""Verifies expires_at is set to 12 hours in both login and login_sso."""
import jwt
import datetime
# 1. Test standard login expiration
registry.user_service.create_user("exp_user", "password123")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_res = auth_stub.login(connpy_pb2.LoginRequest(username="exp_user", password="password123"))
now = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
expected_expires_12h = now + 12 * 3600
# Allow a 10s buffer for execution lag
assert abs(login_res.expires_at - expected_expires_12h) < 10
# 2. Test SSO login expiration
registry._shared_config.config["sso"] = {
"providers": {
"authelia": {
"secret": "sso-secret",
"username_claim": "sub",
"algorithms": ["HS256"]
}
}
}
token = jwt.encode({"sub": "sso_exp_user"}, "sso-secret", algorithm="HS256")
login_sso_res = auth_stub.login_sso(connpy_pb2.LoginSSORequest(
username="sso_exp_user",
id_token=token,
provider="authelia"
))
assert abs(login_sso_res.expires_at - expected_expires_12h) < 10