| # 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 base64 | |
| import hashlib | |
| import os | |
| import secrets | |
| import time | |
| import requests | |
| import openserverless.common.response_builder as res_builder | |
| from openserverless.impl.auth.auth_service import AuthService | |
| _DEVICE_FLOWS = {} | |
| class OidcDeviceFlowService: | |
| def __init__( | |
| self, | |
| environ=os.environ, | |
| http_client=requests, | |
| auth_service=None, | |
| store=None, | |
| now=None, | |
| ): | |
| self._environ = environ | |
| self._http_client = http_client | |
| self._auth_service = auth_service if auth_service is not None else AuthService(environ=environ) | |
| self._store = store if store is not None else _DEVICE_FLOWS | |
| self._now = now if now is not None else time.time | |
| def start(self, requested_namespace=None): | |
| try: | |
| verifier, challenge = self._create_pkce_pair() | |
| response = self._http_client.post( | |
| self._device_authorization_url(), | |
| data=self._device_authorization_form(challenge), | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| timeout=10, | |
| ) | |
| payload = response.json() | |
| except Exception: | |
| return res_builder.build_error_message("Unable to start SSO login", 502) | |
| if response.status_code >= 400: | |
| return res_builder.build_error_message( | |
| self._provider_error_message(payload, "Unable to start SSO login"), | |
| 502, | |
| ) | |
| flow_id = secrets.token_urlsafe(32) | |
| expires_in = int(payload.get("expires_in", 600)) | |
| self._cleanup_expired() | |
| self._store[flow_id] = { | |
| "device_code": payload["device_code"], | |
| "verifier": verifier, | |
| "expires_at": self._now() + expires_in, | |
| "interval": int(payload.get("interval", 5)), | |
| "requested_namespace": requested_namespace, | |
| } | |
| return res_builder.build_response_with_data( | |
| { | |
| "flow_id": flow_id, | |
| "user_code": payload.get("user_code"), | |
| "verification_uri": payload.get("verification_uri"), | |
| "verification_uri_complete": payload.get("verification_uri_complete"), | |
| "expires_in": expires_in, | |
| "interval": int(payload.get("interval", 5)), | |
| } | |
| ) | |
| def poll(self, flow_id): | |
| flow = self._store.get(flow_id) | |
| if not flow: | |
| return res_builder.build_error_message("Invalid SSO flow", 404) | |
| if self._now() >= flow["expires_at"]: | |
| self._store.pop(flow_id, None) | |
| return res_builder.build_error_message("SSO login expired", 400) | |
| try: | |
| response = self._http_client.post( | |
| self._token_url(), | |
| data=self._token_form(flow), | |
| 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 poll SSO login", 502) | |
| error = payload.get("error") | |
| if error in ("authorization_pending", "slow_down"): | |
| return res_builder.build_response_with_data( | |
| { | |
| "status": "pending", | |
| "message": error, | |
| "interval": flow["interval"], | |
| }, | |
| 202, | |
| ) | |
| if response.status_code >= 400: | |
| self._store.pop(flow_id, None) | |
| return res_builder.build_error_message( | |
| self._provider_error_message(payload, "SSO login failed"), | |
| 401, | |
| ) | |
| access_token = payload.get("access_token") | |
| if not access_token: | |
| self._store.pop(flow_id, None) | |
| return res_builder.build_error_message("SSO login did not return an access token", 401) | |
| self._store.pop(flow_id, None) | |
| return self._auth_service.login_oidc( | |
| access_token, | |
| 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") | |
| 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 _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, extra_sensitive=None): | |
| message = payload.get("error_description") or payload.get("error") or fallback | |
| for sensitive in [self._client_secret()] + list(extra_sensitive or []): | |
| if sensitive: | |
| message = message.replace(sensitive, "<redacted>") | |
| 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" | |
| ) | |
| def _token_url(self): | |
| return self._environ.get("OIDC_TOKEN_URL") or ( | |
| f"{self._issuer_url()}/protocol/openid-connect/token" | |
| ) | |
| def _issuer_url(self): | |
| issuer = self._environ.get("OIDC_ISSUER_URL") | |
| if not issuer: | |
| raise ValueError("missing OIDC_ISSUER_URL") | |
| return issuer.rstrip("/") | |
| def _cleanup_expired(self): | |
| now = self._now() | |
| expired = [flow_id for flow_id, flow in self._store.items() if now >= flow["expires_at"]] | |
| for flow_id in expired: | |
| self._store.pop(flow_id, None) | |
| def _create_pkce_pair(self): | |
| verifier = self._base64_url(secrets.token_bytes(32)) | |
| challenge = self._base64_url(hashlib.sha256(verifier.encode("ascii")).digest()) | |
| return verifier, challenge | |
| def _base64_url(self, value): | |
| return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") |