blob: d2e6eab4181c0b23d5d148a37bf76ab6eddfc9d1 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
import unittest
import openserverless.common.response_builder as res_builder
from openserverless import app
from openserverless.impl.auth.oidc_device_flow_service import OidcDeviceFlowService
class FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def json(self):
return self._payload
class FakeHttpClient:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def post(self, url, data=None, auth=None, headers=None, timeout=None):
self.calls.append(
{
"url": url,
"data": data,
"auth": auth,
"headers": headers,
"timeout": timeout,
}
)
return self.responses.pop(0)
class FakeAuthService:
def __init__(self):
self.tokens = []
def login_oidc(self, access_token, expected_namespace=None):
self.tokens.append(
{
"access_token": access_token,
"expected_namespace": expected_namespace,
}
)
return res_builder.build_response_with_data(
{
"LOGIN": "michelem",
"NAMESPACE": "michelem",
"AUTH": "uuid:key",
}
)
class OidcDeviceFlowServiceTest(unittest.TestCase):
def setUp(self):
self.environ = {
"OIDC_ISSUER_URL": "https://keycloak.example.test/realms/openserverless-lab",
"OIDC_AUDIENCE": "openserverless-admin-api",
}
def test_start_creates_backend_flow_without_returning_oidc_details(self):
store = {}
http_client = FakeHttpClient(
[
FakeResponse(
{
"device_code": "device-secret",
"user_code": "ABCD-EFGH",
"verification_uri": "https://keycloak.example.test/device",
"verification_uri_complete": "https://keycloak.example.test/device?user_code=ABCD-EFGH",
"expires_in": 600,
"interval": 5,
}
)
]
)
service = OidcDeviceFlowService(
environ=self.environ,
http_client=http_client,
auth_service=FakeAuthService(),
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.start(requested_namespace="michelem")
self.assertEqual(200, response.status_code)
self.assertEqual("ABCD-EFGH", response.json["user_code"])
self.assertEqual("https://keycloak.example.test/device?user_code=ABCD-EFGH", response.json["verification_uri_complete"])
self.assertIn("flow_id", response.json)
self.assertNotIn("device_code", response.json)
self.assertNotIn("code_verifier", response.json)
self.assertEqual(1, len(store))
self.assertEqual("device-secret", next(iter(store.values()))["device_code"])
self.assertEqual("michelem", next(iter(store.values()))["requested_namespace"])
start_call = http_client.calls[0]
self.assertEqual(
"https://keycloak.example.test/realms/openserverless-lab/protocol/openid-connect/auth/device",
start_call["url"],
)
self.assertEqual("openserverless-admin-api", start_call["data"]["client_id"])
self.assertEqual("S256", start_call["data"]["code_challenge_method"])
self.assertIsNone(start_call["auth"])
self.assertNotIn("client_secret", start_call["data"])
def test_start_uses_client_secret_for_confidential_client_without_returning_it(self):
store = {}
http_client = FakeHttpClient(
[
FakeResponse(
{
"device_code": "device-secret",
"user_code": "ABCD-EFGH",
"verification_uri": "https://keycloak.example.test/device",
"expires_in": 600,
}
)
]
)
environ = dict(self.environ)
environ["OIDC_CLIENT_SECRET"] = "super-secret"
service = OidcDeviceFlowService(
environ=environ,
http_client=http_client,
auth_service=FakeAuthService(),
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.start()
self.assertEqual(200, response.status_code)
self.assertNotIn("client_secret", response.json)
self.assertNotIn("device_code", response.json)
self.assertNotIn("code_verifier", response.json)
self.assertEqual("super-secret", http_client.calls[0]["data"]["client_secret"])
def test_poll_returns_pending_without_exposing_token(self):
store = {
"flow-1": {
"device_code": "device-secret",
"verifier": "verifier",
"expires_at": 2000,
"interval": 5,
}
}
http_client = FakeHttpClient([FakeResponse({"error": "authorization_pending"}, 400)])
service = OidcDeviceFlowService(
environ=self.environ,
http_client=http_client,
auth_service=FakeAuthService(),
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.poll("flow-1")
self.assertEqual(202, response.status_code)
self.assertEqual("pending", response.json["status"])
self.assertEqual("authorization_pending", response.json["message"])
self.assertIn("flow-1", store)
def test_poll_exchanges_token_and_returns_openserverless_login(self):
store = {
"flow-1": {
"device_code": "device-secret",
"verifier": "verifier",
"expires_at": 2000,
"interval": 5,
}
}
auth_service = FakeAuthService()
http_client = FakeHttpClient([FakeResponse({"access_token": "oidc-token"})])
service = OidcDeviceFlowService(
environ=self.environ,
http_client=http_client,
auth_service=auth_service,
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.poll("flow-1")
self.assertEqual(200, response.status_code)
self.assertEqual("michelem", response.json["NAMESPACE"])
self.assertEqual(
[{"access_token": "oidc-token", "expected_namespace": None}],
auth_service.tokens,
)
self.assertNotIn("flow-1", store)
token_call = http_client.calls[0]
self.assertEqual(
"https://keycloak.example.test/realms/openserverless-lab/protocol/openid-connect/token",
token_call["url"],
)
self.assertEqual("device-secret", token_call["data"]["device_code"])
self.assertEqual("verifier", token_call["data"]["code_verifier"])
self.assertIsNone(token_call["auth"])
def test_poll_uses_http_basic_auth_for_confidential_client(self):
store = {
"flow-1": {
"device_code": "device-secret",
"verifier": "verifier",
"expires_at": 2000,
"interval": 5,
}
}
environ = dict(self.environ)
environ["OIDC_CLIENT_SECRET"] = "super-secret"
auth_service = FakeAuthService()
http_client = FakeHttpClient([FakeResponse({"access_token": "oidc-token"})])
service = OidcDeviceFlowService(
environ=environ,
http_client=http_client,
auth_service=auth_service,
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.poll("flow-1")
self.assertEqual(200, response.status_code)
token_call = http_client.calls[0]
self.assertEqual(("openserverless-admin-api", "super-secret"), token_call["auth"])
self.assertEqual("openserverless-admin-api", token_call["data"]["client_id"])
self.assertNotIn("client_secret", token_call["data"])
self.assertNotIn("flow-1", store)
def test_password_grant_exchanges_user_credentials_for_openserverless_login(self):
auth_service = FakeAuthService()
http_client = FakeHttpClient([FakeResponse({"access_token": "oidc-token"})])
service = OidcDeviceFlowService(
environ=self.environ,
http_client=http_client,
auth_service=auth_service,
store={},
now=lambda: 1000,
)
with app.app_context():
response = service.password(
"michelem",
"user-password",
requested_namespace="michelem",
)
self.assertEqual(200, response.status_code)
self.assertEqual("uuid:key", response.json["AUTH"])
self.assertEqual(
[{"access_token": "oidc-token", "expected_namespace": "michelem"}],
auth_service.tokens,
)
token_call = http_client.calls[0]
self.assertEqual(
"https://keycloak.example.test/realms/openserverless-lab/protocol/openid-connect/token",
token_call["url"],
)
self.assertEqual("password", token_call["data"]["grant_type"])
self.assertEqual("michelem", token_call["data"]["username"])
self.assertEqual("user-password", token_call["data"]["password"])
self.assertIsNone(token_call["auth"])
def test_password_grant_uses_http_basic_auth_for_confidential_client(self):
environ = dict(self.environ)
environ["OIDC_CLIENT_SECRET"] = "super-secret"
http_client = FakeHttpClient([FakeResponse({"access_token": "oidc-token"})])
service = OidcDeviceFlowService(
environ=environ,
http_client=http_client,
auth_service=FakeAuthService(),
store={},
now=lambda: 1000,
)
with app.app_context():
response = service.password("michelem", "user-password")
self.assertEqual(200, response.status_code)
token_call = http_client.calls[0]
self.assertEqual(("openserverless-admin-api", "super-secret"), token_call["auth"])
self.assertEqual("openserverless-admin-api", token_call["data"]["client_id"])
self.assertNotIn("client_secret", token_call["data"])
def test_password_grant_provider_errors_do_not_leak_password_or_secret(self):
environ = dict(self.environ)
environ["OIDC_CLIENT_SECRET"] = "super-secret"
http_client = FakeHttpClient(
[
FakeResponse(
{
"error": "invalid_grant",
"error_description": "bad password user-password with secret super-secret",
},
401,
)
]
)
service = OidcDeviceFlowService(
environ=environ,
http_client=http_client,
auth_service=FakeAuthService(),
store={},
now=lambda: 1000,
)
with app.app_context():
response = service.password("michelem", "user-password")
self.assertEqual(401, response.status_code)
self.assertNotIn("user-password", str(response.json))
self.assertNotIn("super-secret", str(response.json))
self.assertIn("<redacted>", response.json["message"])
def test_provider_errors_do_not_leak_client_secret(self):
store = {}
environ = dict(self.environ)
environ["OIDC_CLIENT_SECRET"] = "super-secret"
http_client = FakeHttpClient(
[
FakeResponse(
{
"error": "invalid_client",
"error_description": "bad client secret super-secret",
},
401,
)
]
)
service = OidcDeviceFlowService(
environ=environ,
http_client=http_client,
auth_service=FakeAuthService(),
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.start()
self.assertEqual(502, response.status_code)
self.assertNotIn("super-secret", str(response.json))
self.assertIn("<redacted>", response.json["message"])
def test_poll_passes_requested_namespace_to_oidc_login(self):
store = {
"flow-1": {
"device_code": "device-secret",
"verifier": "verifier",
"expires_at": 2000,
"interval": 5,
"requested_namespace": "michelem",
}
}
auth_service = FakeAuthService()
http_client = FakeHttpClient([FakeResponse({"access_token": "oidc-token"})])
service = OidcDeviceFlowService(
environ=self.environ,
http_client=http_client,
auth_service=auth_service,
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.poll("flow-1")
self.assertEqual(200, response.status_code)
self.assertEqual(
[{"access_token": "oidc-token", "expected_namespace": "michelem"}],
auth_service.tokens,
)
def test_poll_expired_flow(self):
store = {
"flow-1": {
"device_code": "device-secret",
"verifier": "verifier",
"expires_at": 1000,
"interval": 5,
}
}
service = OidcDeviceFlowService(
environ=self.environ,
http_client=FakeHttpClient([]),
auth_service=FakeAuthService(),
store=store,
now=lambda: 1000,
)
with app.app_context():
response = service.poll("flow-1")
self.assertEqual(400, response.status_code)
self.assertNotIn("flow-1", store)
if __name__ == "__main__":
unittest.main()