From 57f4c61c620248a517811c195b18e12c2844e011 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Thu, 9 Jul 2026 19:03:26 +0000 Subject: [PATCH 1/2] Support confidential OIDC device flow --- .../impl/auth/oidc_device_flow_service.py | 55 +++++++--- openserverless/rest/auth.py | 10 +- tests/test_oidc_device_flow_service.py | 100 +++++++++++++++++- 3 files changed, 145 insertions(+), 20 deletions(-) diff --git a/openserverless/impl/auth/oidc_device_flow_service.py b/openserverless/impl/auth/oidc_device_flow_service.py index fcad448..6a8c201 100644 --- a/openserverless/impl/auth/oidc_device_flow_service.py +++ b/openserverless/impl/auth/oidc_device_flow_service.py @@ -51,12 +51,7 @@ def start(self, requested_namespace=None): verifier, challenge = self._create_pkce_pair() response = self._http_client.post( self._device_authorization_url(), - data={ - "client_id": self._client_id(), - "scope": self._environ.get("OIDC_DEVICE_SCOPE", "openid email profile"), - "code_challenge": challenge, - "code_challenge_method": "S256", - }, + data=self._device_authorization_form(challenge), headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=10, ) @@ -66,7 +61,7 @@ def start(self, requested_namespace=None): if response.status_code >= 400: return res_builder.build_error_message( - payload.get("error_description") or payload.get("error") or "Unable to start SSO login", + self._provider_error_message(payload, "Unable to start SSO login"), 502, ) @@ -104,12 +99,8 @@ def poll(self, flow_id): try: response = self._http_client.post( self._token_url(), - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - "client_id": self._client_id(), - "device_code": flow["device_code"], - "code_verifier": flow["verifier"], - }, + data=self._token_form(flow), + auth=self._client_auth(), headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=10, ) @@ -131,7 +122,7 @@ def poll(self, flow_id): if response.status_code >= 400: self._store.pop(flow_id, None) return res_builder.build_error_message( - payload.get("error_description") or error or "SSO login failed", + self._provider_error_message(payload, "SSO login failed"), 401, ) @@ -149,6 +140,42 @@ def poll(self, flow_id): def _client_id(self): return self._environ.get("OIDC_CLIENT_ID") or self._environ.get("OIDC_AUDIENCE") + def _client_secret(self): + return (self._environ.get("OIDC_CLIENT_SECRET") or "").strip() + + def _device_authorization_form(self, challenge): + form = { + "client_id": self._client_id(), + "scope": self._environ.get("OIDC_DEVICE_SCOPE", "openid email profile"), + "code_challenge": challenge, + "code_challenge_method": "S256", + } + client_secret = self._client_secret() + if client_secret: + form["client_secret"] = client_secret + return form + + def _token_form(self, flow): + return { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": self._client_id(), + "device_code": flow["device_code"], + "code_verifier": flow["verifier"], + } + + def _client_auth(self): + client_secret = self._client_secret() + if not client_secret: + return None + return (self._client_id(), client_secret) + + def _provider_error_message(self, payload, fallback): + message = payload.get("error_description") or payload.get("error") or fallback + client_secret = self._client_secret() + if client_secret: + message = message.replace(client_secret, "") + return message + def _device_authorization_url(self): return self._environ.get("OIDC_DEVICE_AUTHORIZATION_URL") or ( f"{self._issuer_url()}/protocol/openid-connect/auth/device" diff --git a/openserverless/rest/auth.py b/openserverless/rest/auth.py index 90492ce..44bb1df 100644 --- a/openserverless/rest/auth.py +++ b/openserverless/rest/auth.py @@ -212,12 +212,12 @@ def start_oidc_device_login(): schema: $ref: '#/definitions/Message' """ - return OidcDeviceFlowService().start( - requested_namespace=_requested_namespace_from_origin( - request.headers.get("Origin"), - request.host, - ) + body = request.get_json(silent=True) or {} + requested_namespace = body.get("namespace") or _requested_namespace_from_origin( + request.headers.get("Origin"), + request.host, ) + return OidcDeviceFlowService().start(requested_namespace=requested_namespace) @app.route('/system/api/v1/auth/oidc/device/poll', methods=['POST']) diff --git a/tests/test_oidc_device_flow_service.py b/tests/test_oidc_device_flow_service.py index 749d6ca..1598749 100644 --- a/tests/test_oidc_device_flow_service.py +++ b/tests/test_oidc_device_flow_service.py @@ -38,11 +38,12 @@ def __init__(self, responses): self.responses = list(responses) self.calls = [] - def post(self, url, data=None, headers=None, timeout=None): + 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, } @@ -123,6 +124,41 @@ def test_start_creates_backend_flow_without_returning_oidc_details(self): ) 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 = { @@ -187,6 +223,68 @@ def test_poll_exchanges_token_and_returns_openserverless_login(self): ) 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_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("", response.json["message"]) def test_poll_passes_requested_namespace_to_oidc_login(self): store = { From 7094d7a8b43b167fba1f5059ca6c1a98720dd1fb Mon Sep 17 00:00:00 2001 From: miki3421 Date: Thu, 9 Jul 2026 21:01:03 +0000 Subject: [PATCH 2/2] Support OIDC password grant login --- .../impl/auth/oidc_device_flow_service.py | 52 +++++++++++- openserverless/rest/auth.py | 51 +++++++++++ tests/test_oidc_device_flow_service.py | 85 +++++++++++++++++++ 3 files changed, 184 insertions(+), 4 deletions(-) diff --git a/openserverless/impl/auth/oidc_device_flow_service.py b/openserverless/impl/auth/oidc_device_flow_service.py index 6a8c201..c8b13d7 100644 --- a/openserverless/impl/auth/oidc_device_flow_service.py +++ b/openserverless/impl/auth/oidc_device_flow_service.py @@ -137,6 +137,41 @@ def poll(self, flow_id): expected_namespace=flow.get("requested_namespace"), ) + def password(self, username, password, requested_namespace=None): + if not username or not password: + return res_builder.build_error_message("Missing SSO username or password", 400) + + try: + response = self._http_client.post( + self._token_url(), + data=self._password_token_form(username, password), + auth=self._client_auth(), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + payload = response.json() + except Exception: + return res_builder.build_error_message("Unable to authenticate with SSO provider", 502) + + if response.status_code >= 400: + return res_builder.build_error_message( + self._provider_error_message( + payload, + "SSO password login failed", + extra_sensitive=[password], + ), + 401, + ) + + access_token = payload.get("access_token") + if not access_token: + return res_builder.build_error_message("SSO password login did not return an access token", 401) + + return self._auth_service.login_oidc( + access_token, + expected_namespace=requested_namespace, + ) + def _client_id(self): return self._environ.get("OIDC_CLIENT_ID") or self._environ.get("OIDC_AUDIENCE") @@ -163,17 +198,26 @@ def _token_form(self, flow): "code_verifier": flow["verifier"], } + def _password_token_form(self, username, password): + return { + "grant_type": "password", + "client_id": self._client_id(), + "username": username, + "password": password, + "scope": self._environ.get("OIDC_PASSWORD_SCOPE", "openid email profile"), + } + def _client_auth(self): client_secret = self._client_secret() if not client_secret: return None return (self._client_id(), client_secret) - def _provider_error_message(self, payload, fallback): + def _provider_error_message(self, payload, fallback, extra_sensitive=None): message = payload.get("error_description") or payload.get("error") or fallback - client_secret = self._client_secret() - if client_secret: - message = message.replace(client_secret, "") + for sensitive in [self._client_secret()] + list(extra_sensitive or []): + if sensitive: + message = message.replace(sensitive, "") return message def _device_authorization_url(self): diff --git a/openserverless/rest/auth.py b/openserverless/rest/auth.py index 44bb1df..98190f2 100644 --- a/openserverless/rest/auth.py +++ b/openserverless/rest/auth.py @@ -265,3 +265,54 @@ def poll_oidc_device_login(): """ body = request.get_json(silent=True) or {} return OidcDeviceFlowService().poll(body.get("flow_id")) + + +@app.route('/system/api/v1/auth/oidc/password', methods=['POST']) +def oidc_password_login(): + """ + Backend-managed OIDC password grant login + --- + tags: + - Authentication Api + summary: Authenticate user with OIDC password grant + description: admin-api exchanges user credentials with the configured OIDC provider, validates the returned token and returns OpenServerless namespace data. User password and OIDC tokens are not returned. + operationId: oidcPasswordLogin + consumes: + - application/json + parameters: + - in: body + name: OidcPasswordLogin + required: true + schema: + type: object + properties: + username: + type: string + password: + type: string + namespace: + type: string + responses: + 200: + description: Authentication successful. Returns OpenServerless user data. + schema: + $ref: '#/definitions/MessageData' + 400: + description: Missing username or password. + schema: + $ref: '#/definitions/Message' + 401: + description: SSO login failed. + schema: + $ref: '#/definitions/Message' + 502: + description: admin-api could not authenticate with the configured OIDC provider. + schema: + $ref: '#/definitions/Message' + """ + body = request.get_json(silent=True) or {} + return OidcDeviceFlowService().password( + body.get("username"), + body.get("password"), + requested_namespace=body.get("namespace"), + ) diff --git a/tests/test_oidc_device_flow_service.py b/tests/test_oidc_device_flow_service.py index 1598749..d2e6eab 100644 --- a/tests/test_oidc_device_flow_service.py +++ b/tests/test_oidc_device_flow_service.py @@ -256,6 +256,91 @@ def test_poll_uses_http_basic_auth_for_confidential_client(self): 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("", response.json["message"]) + def test_provider_errors_do_not_leak_client_secret(self): store = {} environ = dict(self.environ)