| # 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() |