From 70eaef0a0cf1317f03f6d8e26dab011df89f862b Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 5 Sep 2025 16:52:45 -0300 Subject: [PATCH 01/26] Add initial code --- navbar/ui/yarn.lock | 8 +- openstack/code/Page.php | 7 +- .../utils/apis/AbstractRestfulJsonApi.php | 17 +- openstackid/_config/routes.yml | 1 + .../restfull_api/OIDCSessionBootstrapApi.php | 245 ++++++++++++++++++ .../Includes/Page_LoggedInVerification.ss | 67 +++++ themes/openstack/templates/Page.ss | 1 + 7 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php create mode 100644 themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss diff --git a/navbar/ui/yarn.lock b/navbar/ui/yarn.lock index e060e4ff8b..522a95a62e 100644 --- a/navbar/ui/yarn.lock +++ b/navbar/ui/yarn.lock @@ -1162,10 +1162,10 @@ ms@2.0.0: resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== -navigation-widget@^1.0.13: - version "1.0.13" - resolved "https://registry.yarnpkg.com/navigation-widget/-/navigation-widget-1.0.13.tgz#f40060aa78a99a8495ce5ddf9649f93e027833cf" - integrity sha512-4wDK9gQYi9c8cTmVXVH7itIQXpr4zzVLbzc69w81NX0Cb6hVdMkcUbTuyviTfOHn/NFeqDRtd0fDnqn/AlwBeQ== +navigation-widget@1.0.27: + version "1.0.27" + resolved "https://registry.yarnpkg.com/navigation-widget/-/navigation-widget-1.0.27.tgz#1f84bba8bd81ff34f01ea8b476bcb2c27a9fc867" + integrity sha512-JXB0xF3sJI2b1STnS3ak9stpIXzaQZmhPw26yf1ELx8h+UPyyRTD3NW+23ZNMqGkx+X3cH3Za94GQ2KWbovchQ== object-assign@^4.0.1, object-assign@^4.1.1: version "4.1.1" diff --git a/openstack/code/Page.php b/openstack/code/Page.php index 1097f949f7..bdfa653352 100644 --- a/openstack/code/Page.php +++ b/openstack/code/Page.php @@ -137,7 +137,7 @@ public static function IconShortCodeHandler($arguments, $caption = null, $parser //return the customized template return $template->process(new ArrayData($customise)); - } + } function requireDefaultRecords() @@ -274,6 +274,11 @@ public function getTime() return time(); } + public function getSecurityToken() + { + return SecurityToken::inst() ? SecurityToken::inst()->getValue() : null; + } + protected function CustomScripts() { $js_files = [ diff --git a/openstack/code/utils/apis/AbstractRestfulJsonApi.php b/openstack/code/utils/apis/AbstractRestfulJsonApi.php index 41ea3fb247..77b311fc49 100644 --- a/openstack/code/utils/apis/AbstractRestfulJsonApi.php +++ b/openstack/code/utils/apis/AbstractRestfulJsonApi.php @@ -11,8 +11,8 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ -use Doctrine\Common\Annotations\AnnotationReader; use Openstack\Annotations\CachedMethod; +use Doctrine\Common\Annotations\AnnotationReader; /** * Class AbstractRestfulJsonApi */ @@ -579,6 +579,21 @@ protected function created($id) return $response; } + /** + * @return SS_HTTPResponse + */ + public function badRequest($message = "Bad Request") + { + $response = new SS_HTTPResponse(); + $response->setStatusCode(400); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(json_encode( + ['error' => 'validation', 'messages' => [['message' => $message]]] + )); + + return $response; + } + /** * @return SS_HTTPResponse diff --git a/openstackid/_config/routes.yml b/openstackid/_config/routes.yml index 9379bbb60f..4adedd4f08 100644 --- a/openstackid/_config/routes.yml +++ b/openstackid/_config/routes.yml @@ -4,4 +4,5 @@ Name: openstackidroutes Director: rules: 'OpenStackIdAuthenticator': 'OpenStackIdAuthenticator' + 'oidc/session/bootstrap': 'OIDCSessionBootstrapApi' diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php new file mode 100644 index 0000000000..96f2542ab1 --- /dev/null +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -0,0 +1,245 @@ + 'bootstrap', + ]; + + /** + * @var array + */ + private static $allowed_actions = [ + 'bootstrap', + ]; + + /** + * @return bool + */ + protected function isApiCall() + { + $request = $this->getRequest(); + if (is_null($request)) return false; + return true; + } + + /** + * @return bool + */ + protected function authorize() + { + return true; + } + + /** + * @return bool + */ + protected function authenticate() + { + return true; + } + + /** + * Bootstrap OIDC session + * @param SS_HTTPRequest $request + * @return SS_HTTPResponse + */ + public function bootstrap(SS_HTTPRequest $request) + { + try { + // Check if request method is POST + if (!$request->isPOST()) { + return $this->methodNotAllowed('Only POST requests are allowed'); + } + + // Check Content-Type header + $contentType = $request->getHeader('Content-Type'); + if (strpos($contentType, 'application/json') === false) { + return $this->validationError(['Content-Type must be application/json']); + } + + // Check Authorization header + $authHeader = $request->getHeader('Authorization'); + if (empty($authHeader) || strpos($authHeader, 'Bearer ') !== 0) { + return $this->validationError(['Authorization header with Bearer token is required']); + } + + // Extract access token + $accessToken = str_replace('Bearer ', '', $authHeader); + if (empty($accessToken)) { + return $this->validationError(['Access token is required']); + } + + // Check X-CSRF-Token header + $csrfToken = $request->getHeader('X-CSRF-Token'); + if (empty($csrfToken)) { + return $this->validationError(['X-CSRF-Token header is required']); + } + + // Check X-CSRF-Token header + + if ($csrfToken !== $this->getSecurityToken()) { + return $this->badRequest('X-CSRF-Token header is invalid'); + } + + // Get JSON payload + $data = $this->getJsonRequest(); + if (!$data) { + return $this->badRequest('Invalid JSON payload'); + } + + // Validate access token with OIDC provider + try { + $oidc = OIDCClientFactory::build(); + $tokenData = $oidc->introspectToken($accessToken); + + if (isset($tokenData->error) || (isset($tokenData->active) && !$tokenData->active)) { + return $this->validationError(['Invalid or expired access token']); + } + } catch (Exception $ex) { + SS_Log::log($ex, SS_Log::WARN); + return $this->validationError(['Token validation failed']); + } + + // Make dummy call to external server + $externalResponse = $this->makeExternalCall($accessToken, $data); + + // Log the bootstrap attempt + SS_Log::log( + sprintf('OIDC session bootstrap successful for token: %s', substr($accessToken, 0, 10) . '...'), + SS_Log::INFO + ); + + return $this->ok([ + 'status' => 'success', + 'message' => 'Session bootstrapped successfully', + 'timestamp' => time(), + 'external_response' => $externalResponse + ]); + + } catch (EntityValidationException $ex1) { + SS_Log::log($ex1, SS_Log::WARN); + return $this->validationError($ex1->getMessages()); + } catch (Exception $ex) { + SS_Log::log($ex, SS_Log::ERR); + return $this->serverError(); + } + } + + /** + * Make a dummy call to an external server + * @param string $accessToken + * @param array $data + * @return array + */ + private function makeExternalCall($accessToken, $data) + { + try { + // Dummy external API endpoint (replace with actual external server URL) + $externalUrl = 'https://jsonplaceholder.typicode.com/posts'; + + // Prepare payload for external call + $payload = [ + 'title' => 'OIDC Session Bootstrap', + 'body' => 'Session bootstrap request from OpenStack.org', + 'userId' => 1, + 'metadata' => [ + 'timestamp' => isset($data['timestamp']) ? $data['timestamp'] : time(), + 'userAgent' => isset($data['userAgent']) ? $data['userAgent'] : 'Unknown', + 'token_hash' => hash('sha256', $accessToken) // Don't send the actual token + ] + ]; + + // Initialize cURL + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $externalUrl, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Accept: application/json', + 'User-Agent: OpenStack.org/1.0' + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2 + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + SS_Log::log("External API call failed: " . $error, SS_Log::WARN); + return [ + 'status' => 'error', + 'message' => 'External API call failed', + 'error' => $error + ]; + } + + if ($httpCode >= 200 && $httpCode < 300) { + $responseData = json_decode($response, true); + return [ + 'status' => 'success', + 'http_code' => $httpCode, + 'data' => $responseData ?: ['raw_response' => $response] + ]; + } else { + return [ + 'status' => 'error', + 'http_code' => $httpCode, + 'message' => 'External API returned error status' + ]; + } + + } catch (Exception $ex) { + SS_Log::log("External API call exception: " . $ex->getMessage(), SS_Log::ERR); + return [ + 'status' => 'error', + 'message' => 'External API call failed with exception', + 'error' => $ex->getMessage() + ]; + } + } + + /** + * Return method not allowed response + * @param string $message + * @return SS_HTTPResponse + */ + protected function methodNotAllowed($message = 'Method Not Allowed') + { + return (new SS_HTTPResponse($message, 405)) + ->addHeader('Content-Type', 'application/json') + ->addHeader('Allow', 'POST'); + } + + protected function getSecurityToken() + { + return SecurityToken::inst() ? SecurityToken::inst()->getValue() : null; + } +} diff --git a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss new file mode 100644 index 0000000000..92fa91141c --- /dev/null +++ b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss @@ -0,0 +1,67 @@ + \ No newline at end of file diff --git a/themes/openstack/templates/Page.ss b/themes/openstack/templates/Page.ss index 3c16e53b88..f82667ae70 100644 --- a/themes/openstack/templates/Page.ss +++ b/themes/openstack/templates/Page.ss @@ -32,6 +32,7 @@ <% include Page_GoogleAnalytics %> <% include Page_MicrosoftAdvertising %> <% include Page_LinkedinInsightTracker %> + <% include Page_LoggedInVerification %> From cfc492fd729186db636619ab5a9f045e6143e022 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 5 Sep 2025 18:26:19 -0300 Subject: [PATCH 02/26] Add call fixes --- .../restfull_api/OIDCSessionBootstrapApi.php | 26 ++++++++++--------- .../Includes/Page_LoggedInVerification.ss | 8 +++--- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index 96f2542ab1..0696ccfb4f 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -17,19 +17,20 @@ * Handles OIDC session bootstrap requests */ final class OIDCSessionBootstrapApi extends AbstractRestfulJsonApi -{ +{ private static $api_prefix = 'api/v1/oidc/session/bootstrap'; + /** * @var array */ private static $url_handlers = [ - 'POST ' => 'bootstrap', + 'POST ' => 'index', ]; /** * @var array */ private static $allowed_actions = [ - 'bootstrap', + 'index', ]; /** @@ -63,7 +64,7 @@ protected function authenticate() * @param SS_HTTPRequest $request * @return SS_HTTPResponse */ - public function bootstrap(SS_HTTPRequest $request) + public function index(SS_HTTPRequest $request) { try { // Check if request method is POST @@ -90,9 +91,9 @@ public function bootstrap(SS_HTTPRequest $request) } // Check X-CSRF-Token header - $csrfToken = $request->getHeader('X-CSRF-Token'); + $csrfToken = $request->getHeader('X-CSRF-Token') ?: $request->getHeader('X-Csrf-Token'); if (empty($csrfToken)) { - return $this->validationError(['X-CSRF-Token header is required']); + return $this->validationError(['X-CSRF-Token header is required', "headers" => $request->getHeaders()]); } // Check X-CSRF-Token header @@ -129,12 +130,13 @@ public function bootstrap(SS_HTTPRequest $request) SS_Log::INFO ); - return $this->ok([ - 'status' => 'success', - 'message' => 'Session bootstrapped successfully', - 'timestamp' => time(), - 'external_response' => $externalResponse - ]); + $success = $externalResponse['status'] === 'success'; + $response = new SS_HTTPResponse(); + $response->setStatusCode($success ? $externalResponse['http_code'] : 400); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody($success ? json_encode($externalResponse['data']) : json_encode(['error' => $externalResponse['message']])); + // $response->setBody(''); + return $response; } catch (EntityValidationException $ex1) { SS_Log::log($ex1, SS_Log::WARN); diff --git a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss index 92fa91141c..dc2ead4948 100644 --- a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss +++ b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss @@ -1,5 +1,4 @@ - \ No newline at end of file + +<% end_if %> \ No newline at end of file From d9f13220f97e43e8a058c1231198b42298ba4aaf Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Wed, 10 Sep 2025 19:26:32 -0300 Subject: [PATCH 16/26] Add debug log --- .../restfull_api/OIDCSessionBootstrapApi.php | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index c9bf325206..08d2d81559 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -97,9 +97,17 @@ public function index(SS_HTTPRequest $request) return $tokenData; } - if ($tokenData === false or isset($tokenData['error']) or !$tokenData['active']) { + if ($tokenData === false) + { + SS_Log::log(sprintf("%s token %s introspection returned false", __METHOD__, $accessToken), SS_Log::WARN); + } + + if ( + $tokenData === false or + (is_array($tokenData) and isset($tokenData['error'])) + ) { $error = 'Invalid or expired access token'; - if ($tokenData and $tokenData['error']) { + if (is_array($tokenData) and !empty($tokenData['error'])) { $error = $tokenData['error']; } return $this->validationError([$error], 400); @@ -108,16 +116,13 @@ public function index(SS_HTTPRequest $request) // at this point we have a valid and active token // we can create a session for the user $member = null; - if ($tokenData['user_id']) { - $member = Member::get()->filter('Email', $tokenData['user_email'])->first(); - } if (!$member && $tokenData['user_email']) { $member = Member::get()->filter('Email', $tokenData['user_email'])->first(); } if (!$member) { - return $this->validationError(['User not found'], 400); + return $this->validationError(['User not found'], 404); } // log in the user $member->logIn(); @@ -204,19 +209,11 @@ private function doIntrospectionRequest(string $token_value) try { SS_Log::log(sprintf(__METHOD__ . " token %s", $token_value), SS_Log::DEBUG); - $stack = HandlerStack::create(); $client_id = defined('OIDC_PUBLIC_APP_CLIENT_ID') ? OIDC_PUBLIC_APP_CLIENT_ID : ''; $client_secret = defined('OIDC_PUBLIC_APP_CLIENT_SECRET') ? OIDC_PUBLIC_APP_CLIENT_SECRET : ''; $auth_server_url = defined('IDP_OPENSTACKID_URL') ? IDP_OPENSTACKID_URL : ''; $verify = defined('OIDC_PUBLIC_APP_VERIFY_HOST') ? OIDC_PUBLIC_APP_VERIFY_HOST : false; - $stack->push(GuzzleRetryMiddleware::factory()); - $client = new Client([ - 'handler' => $stack, - 'verify' => $verify, - 'timeout' => 120, - ]); - if (empty($client_id)) { return $this->validationError('OIDC_CLIENT_ID_PUBLIC is not configured'); } @@ -229,34 +226,54 @@ private function doIntrospectionRequest(string $token_value) return $this->validationError('IDP_OPENSTACKID_URL is not configured'); } + $stack = HandlerStack::create(); + $stack->push(GuzzleRetryMiddleware::factory()); + + $client = new Client([ + 'base_uri' => $auth_server_url, + 'handler' => $stack, + 'verify' => $verify, + 'timeout' => 120, + ]); + // http://docs.guzzlephp.org/en/stable/request-options.html $options = [ 'form_params' => ['token' => $token_value], 'auth' => [$client_id, $client_secret], - 'http_errors' => true + 'http_errors' => true, + 'synchronous' => true, ]; - $response = $client->request('POST', "{$auth_server_url}/oauth2/token/introspection", $options); + $endpoint = "/oauth2/token/introspection"; + SS_Log::log(sprintf(__METHOD__ . " DEBUG options %s", json_encode($options)), SS_Log::DEBUG); + $response = $client->request('POST', $endpoint, $options); $content_type = $response->getHeaderLine('content-type'); $is_json = in_array("application/json", explode(';', $content_type)); + $body = $response->getBody()->getContents(); if (!$is_json) { // invalid content type - $body = $response->getBody()->getContents(); $status = $response->getStatusCode(); SS_Log::log(sprintf(__METHOD__ . " status %s content type %s body %s", $status, $content_type, $body), SS_Log::WARN); - throw new \Exception($body); + return $this->validationError($body, 500); + } + SS_Log::log(sprintf(__METHOD__ . " DEBUG response %s", $body), SS_Log::DEBUG); + + $jsonResponse = json_decode($body, true); + if (!$jsonResponse) { + SS_Log::log(sprintf(__METHOD__ . " invalid JSON response %s", $body), SS_Log::ERR); + return $this->validationError('Invalid JSON response from IDP', 500); } - $jsonResponse = json_decode($response->getBody()->getContents(), true); + SS_Log::log(sprintf(__METHOD__ . " DEBUG JSON RESPONSE %s", json_encode($jsonResponse, JSON_PRETTY_PRINT)), SS_Log::DEBUG); $defaults = array_fill_keys(["error", "user_id", "user_external_id", "user_identifier", "user_email", "active"], null); $defaults['active'] = false; $jsonResponse = array_merge($defaults, $jsonResponse); $json = [ - 'active' => !!$jsonResponse['active'], 'user_id' => $jsonResponse['user_id'] ?: $jsonResponse['user_external_id'], 'user_email' => $jsonResponse['user_email'] ?: $jsonResponse['user_identifier'], ]; + SS_Log::log(sprintf(__METHOD__ . " DEBUG JSON RETURN %s", json_encode($json, JSON_PRETTY_PRINT)), SS_Log::DEBUG); return $json; } catch (RequestException $ex) { @@ -268,7 +285,7 @@ private function doIntrospectionRequest(string $token_value) "error" => "Server Error", "message" => $ex->getMessage(), ]; - if (defined('SS_ENVIRONMENT_TYPE') && SS_ENVIRONMENT_TYPE === 'dev') { + if (defined('SS_ENVIRONMENT_TYPE') && SS_ENVIRONMENT_TYPE !== 'production') { $data['trace'] = $ex->getTrace(); } $response = new SS_HTTPResponse(); From 3cf01b3c915411cd18e6534e057fb725cdbcb675 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 11:06:52 -0300 Subject: [PATCH 17/26] Add same-origin security verification --- .../restfull_api/OIDCSessionBootstrapApi.php | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index 08d2d81559..0f5765c3ea 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -97,15 +97,14 @@ public function index(SS_HTTPRequest $request) return $tokenData; } - if ($tokenData === false) - { + if ($tokenData === false) { SS_Log::log(sprintf("%s token %s introspection returned false", __METHOD__, $accessToken), SS_Log::WARN); } if ( - $tokenData === false or - (is_array($tokenData) and isset($tokenData['error'])) - ) { + $tokenData === false or + (is_array($tokenData) and isset($tokenData['error'])) + ) { $error = 'Invalid or expired access token'; if (is_array($tokenData) and !empty($tokenData['error'])) { $error = $tokenData['error']; @@ -165,20 +164,15 @@ private function validateRequestData(SS_HTTPRequest $request) // Check Authorization header $authHeader = $request->getHeader('Authorization'); + $accessToken = str_replace('Bearer ', '', $authHeader ?: ''); if (empty($authHeader) || strpos($authHeader, 'Bearer ') !== 0) { return $this->validationError(['Authorization header with Bearer token is required']); } - // Extract access token - $accessToken = str_replace('Bearer ', '', $authHeader); - if (empty($accessToken)) { - return $this->validationError(['Access token is required']); - } - // Check X-CSRF-Token header $csrfToken = $request->getHeader('X-CSRF-Token') ?: $request->getHeader('X-Csrf-Token'); if (empty($csrfToken)) { - return $this->validationError(['X-CSRF-Token header is required', "headers" => $request->getHeaders()]); + return $this->validationError(['X-CSRF-Token header is required']); } // Check X-CSRF-Token header value @@ -186,6 +180,19 @@ private function validateRequestData(SS_HTTPRequest $request) return $this->badRequest('X-CSRF-Token header is invalid'); } + // Check Sec-Fetch-Site header + $SecFetchSite = $request->getHeader('Sec-Fetch-Site') ?: ""; + if ($SecFetchSite !== 'same-origin') { + return $this->validationError(['Sec-Fetch-Site header is required and must be same-origin']); + } + + // Check Referer and Origin headers + $Referer = $request->getHeader('Referer') ?: ""; + $Origin = $request->getHeader('Origin') ?: ""; + if (empty($Referer) or empty($Origin) || $Origin !== $Referer) { + return $this->validationError(['same-origin is in place, Referer and Origin headers must be present and match']); + } + // Get JSON payload $data = $this->getJsonRequest(); if (!$data) { From 10381cec3c627fef4a486ba32052b1b1ba354294 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 11:11:38 -0300 Subject: [PATCH 18/26] Send bad request when security validations are not met --- .../restfull_api/OIDCSessionBootstrapApi.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index 0f5765c3ea..dd9d6a38a6 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -159,20 +159,20 @@ private function validateRequestData(SS_HTTPRequest $request) // Check Content-Type header $contentType = $request->getHeader('Content-Type'); if (strpos($contentType, 'application/json') === false) { - return $this->validationError(['Content-Type must be application/json']); + return $this->badRequest('Content-Type must be application/json'); } // Check Authorization header $authHeader = $request->getHeader('Authorization'); $accessToken = str_replace('Bearer ', '', $authHeader ?: ''); if (empty($authHeader) || strpos($authHeader, 'Bearer ') !== 0) { - return $this->validationError(['Authorization header with Bearer token is required']); + return $this->badRequest('Authorization header with Bearer token is required'); } // Check X-CSRF-Token header $csrfToken = $request->getHeader('X-CSRF-Token') ?: $request->getHeader('X-Csrf-Token'); if (empty($csrfToken)) { - return $this->validationError(['X-CSRF-Token header is required']); + return $this->badRequest('X-CSRF-Token header is required'); } // Check X-CSRF-Token header value @@ -183,14 +183,14 @@ private function validateRequestData(SS_HTTPRequest $request) // Check Sec-Fetch-Site header $SecFetchSite = $request->getHeader('Sec-Fetch-Site') ?: ""; if ($SecFetchSite !== 'same-origin') { - return $this->validationError(['Sec-Fetch-Site header is required and must be same-origin']); + return $this->badRequest('Sec-Fetch-Site header is required and must be same-origin'); } // Check Referer and Origin headers $Referer = $request->getHeader('Referer') ?: ""; $Origin = $request->getHeader('Origin') ?: ""; if (empty($Referer) or empty($Origin) || $Origin !== $Referer) { - return $this->validationError(['same-origin is in place, Referer and Origin headers must be present and match']); + return $this->badRequest('same-origin is in place, Referer and Origin headers must be present and match'); } // Get JSON payload From 5182de38364a6e7f3821b054479354f82691625f Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 11:23:37 -0300 Subject: [PATCH 19/26] Enhance check for same-origin --- .../restfull_api/OIDCSessionBootstrapApi.php | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index dd9d6a38a6..f6bc0688c1 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -186,11 +186,47 @@ private function validateRequestData(SS_HTTPRequest $request) return $this->badRequest('Sec-Fetch-Site header is required and must be same-origin'); } - // Check Referer and Origin headers - $Referer = $request->getHeader('Referer') ?: ""; - $Origin = $request->getHeader('Origin') ?: ""; - if (empty($Referer) or empty($Origin) || $Origin !== $Referer) { - return $this->badRequest('same-origin is in place, Referer and Origin headers must be present and match'); + // Check Referer and Origin headers for same-origin + $origin = $request->getHeader('Origin') ?: ""; + $referer = $request->getHeader('Referer') ?: ""; + + if (empty($referer) || empty($origin)) { + return $this->badRequest('same-origin is in place, Referer and Origin headers must be present'); + } + + // Parse origins from both URLs + $originParsed = parse_url($origin); + $refererParsed = parse_url($referer); + + if (!$originParsed || !$refererParsed) { + return $this->badRequest('Invalid Origin or Referer URL format'); + } + + // Compare scheme, host, and port for same-origin check + $originScheme = $originParsed['scheme'] ?? ''; + $originHost = $originParsed['host'] ?? ''; + $originPort = $originParsed['port'] ?? ($originScheme === 'https' ? 443 : 80); + + $refererScheme = $refererParsed['scheme'] ?? ''; + $refererHost = $refererParsed['host'] ?? ''; + $refererPort = $refererParsed['port'] ?? ($refererScheme === 'https' ? 443 : 80); + + if ($originScheme !== $refererScheme || $originHost !== $refererHost || $originPort !== $refererPort) { + SS_Log::log( + sprintf( + 'Same-origin check failed: Origin=%s (scheme=%s, host=%s, port=%d) vs Referer=%s (scheme=%s, host=%s, port=%d)', + $origin, + $originScheme, + $originHost, + $originPort, + $referer, + $refererScheme, + $refererHost, + $refererPort + ), + SS_Log::DEBUG + ); + return $this->badRequest('same-origin check failed: Origin and Referer headers must have the same origin'); } // Get JSON payload From 43ebeb1a76c077f4ae08bdf147d7a1db04c8c9a1 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 12:00:21 -0300 Subject: [PATCH 20/26] Move checked to an external localStorage key, moved to authInfoChecked --- .../Layout/Includes/Page_LoggedInVerification.ss | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss index 9025ba4e75..48c8ba5c00 100644 --- a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss +++ b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss @@ -11,10 +11,10 @@ accessToken, idToken, expiresIn, - checked, } = (JSON.parse(authInfo) || {}); delete authInfo; + const checked = localStorage.authInfoChecked === "true"; if (checked) return; const isValid = accessToken && @@ -28,13 +28,7 @@ return; } - localStorage.setItem('authInfo', JSON.stringify({ - accessTokenUpdatedAt, - accessToken, - idToken, - expiresIn, - checked: true - })); + localStorage.setItem('authInfoChecked', "true"); // Check token validity and bootstrap session jQuery.ajax({ From 7bfd3c9c9e35edcd074704ca828f545b53e853a1 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 12:09:00 -0300 Subject: [PATCH 21/26] Rename ENV vars --- README.md | 6 +- .../restfull_api/OIDCSessionBootstrapApi.php | 10 +- sample._ss_environment.php | 137 +++++++++--------- 3 files changed, 78 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index d89dac1450..cae9530cbb 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ define('OIDC_CLIENT_SECRET', ''); define('OIDC_VERIFY_HOST',false); -define('OIDC_PUBLIC_APP_CLIENT_ID', ''); -define('OIDC_PUBLIC_APP_CLIENT_SECRET', ''); +define('SESSION_CHECKER_OAUTH2_APP_CLIENT_ID', ''); +define('SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET', ''); //set true on production mode, otherwise false -define('OIDC_PUBLIC_APP_VERIFY_HOST', false); +define('SESSION_CHECKER_OAUTH2_APP_VERIFY_HOST', false); ```` diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index f6bc0688c1..6842488acd 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -252,17 +252,17 @@ private function doIntrospectionRequest(string $token_value) try { SS_Log::log(sprintf(__METHOD__ . " token %s", $token_value), SS_Log::DEBUG); - $client_id = defined('OIDC_PUBLIC_APP_CLIENT_ID') ? OIDC_PUBLIC_APP_CLIENT_ID : ''; - $client_secret = defined('OIDC_PUBLIC_APP_CLIENT_SECRET') ? OIDC_PUBLIC_APP_CLIENT_SECRET : ''; + $client_id = defined('SESSION_CHECKER_OAUTH2_APP_CLIENT_ID') ? SESSION_CHECKER_OAUTH2_APP_CLIENT_ID : ''; + $client_secret = defined('SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET') ? SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET : ''; $auth_server_url = defined('IDP_OPENSTACKID_URL') ? IDP_OPENSTACKID_URL : ''; - $verify = defined('OIDC_PUBLIC_APP_VERIFY_HOST') ? OIDC_PUBLIC_APP_VERIFY_HOST : false; + $verify = defined('SESSION_CHECKER_OAUTH2_APP_VERIFY_HOST') ? SESSION_CHECKER_OAUTH2_APP_VERIFY_HOST : false; if (empty($client_id)) { - return $this->validationError('OIDC_CLIENT_ID_PUBLIC is not configured'); + return $this->validationError('SESSION_CHECKER_OAUTH2_APP_CLIENT_ID is not configured'); } if (empty($client_secret)) { - return $this->validationError('OIDC_CLIENT_SECRET_PUBLIC is not configured'); + return $this->validationError('SESSION_CHECKER_OAUTH2_APP_CLIENT_SECRET is not configured'); } if (empty($auth_server_url)) { diff --git a/sample._ss_environment.php b/sample._ss_environment.php index 7cc1cf59d1..3b60156502 100644 --- a/sample._ss_environment.php +++ b/sample._ss_environment.php @@ -1,12 +1,12 @@ Date: Fri, 12 Sep 2025 12:12:48 -0300 Subject: [PATCH 22/26] Add check for user_external_id first --- .../interfaces/restfull_api/OIDCSessionBootstrapApi.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index 6842488acd..0a6f01afda 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -120,6 +120,14 @@ public function index(SS_HTTPRequest $request) $member = Member::get()->filter('Email', $tokenData['user_email'])->first(); } + if (!$member && $tokenData['user_external_id']) { + $member = Member::get()->filter('ID', $tokenData['user_external_id'])->first(); + } + + if (!$member && $tokenData['user_email']) { + $member = Member::get()->filter('Email', $tokenData['user_email'])->first(); + } + if (!$member) { return $this->validationError(['User not found'], 404); } From f804810bde4d640ab4df200be02a339c2a06ebb5 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 12:16:48 -0300 Subject: [PATCH 23/26] Remove repeated code and fix missing field in request processing --- .../interfaces/restfull_api/OIDCSessionBootstrapApi.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index 0a6f01afda..79fee544ae 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -116,10 +116,6 @@ public function index(SS_HTTPRequest $request) // we can create a session for the user $member = null; - if (!$member && $tokenData['user_email']) { - $member = Member::get()->filter('Email', $tokenData['user_email'])->first(); - } - if (!$member && $tokenData['user_external_id']) { $member = Member::get()->filter('ID', $tokenData['user_external_id'])->first(); } @@ -316,12 +312,12 @@ private function doIntrospectionRequest(string $token_value) } SS_Log::log(sprintf(__METHOD__ . " DEBUG JSON RESPONSE %s", json_encode($jsonResponse, JSON_PRETTY_PRINT)), SS_Log::DEBUG); - $defaults = array_fill_keys(["error", "user_id", "user_external_id", "user_identifier", "user_email", "active"], null); + $defaults = array_fill_keys(["error", "user_external_id", "user_identifier", "user_email"], null); $defaults['active'] = false; $jsonResponse = array_merge($defaults, $jsonResponse); $json = [ - 'user_id' => $jsonResponse['user_id'] ?: $jsonResponse['user_external_id'], + 'user_external_id' => $jsonResponse['user_external_id'], 'user_email' => $jsonResponse['user_email'] ?: $jsonResponse['user_identifier'], ]; SS_Log::log(sprintf(__METHOD__ . " DEBUG JSON RETURN %s", json_encode($json, JSON_PRETTY_PRINT)), SS_Log::DEBUG); From 0babd29cc658a3e11f3fab98e3766fdabb9e62ee Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 19:36:23 -0300 Subject: [PATCH 24/26] chore: Only mark authInfo as sso:bootstrapped true when 204, 404 or 'invalid_token', 'invalid_grant' Escape SecurityToken quotes with a \" in the Page_Controller getter --- openstack/code/Page.php | 2 +- .../Layout/Includes/Page_LoggedInVerification.ss | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/openstack/code/Page.php b/openstack/code/Page.php index 99a8b8ae27..719582c9af 100644 --- a/openstack/code/Page.php +++ b/openstack/code/Page.php @@ -276,7 +276,7 @@ public function getTime() public function getSecurityToken() { - return SecurityToken::inst() ? SecurityToken::inst()->getValue() : null; + return SecurityToken::inst() ? str_replace('"', '\\"', SecurityToken::inst()->getValue()) : null; } public function getIsSSOBootstrapEnabled() diff --git a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss index 48c8ba5c00..74c7a26fea 100644 --- a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss +++ b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss @@ -12,9 +12,8 @@ idToken, expiresIn, } = (JSON.parse(authInfo) || {}); - delete authInfo; - const checked = localStorage.authInfoChecked === "true"; + const checked = localStorage.getItem('sso:bootstrapped') === "true"; if (checked) return; const isValid = accessToken && @@ -28,7 +27,6 @@ return; } - localStorage.setItem('authInfoChecked', "true"); // Check token validity and bootstrap session jQuery.ajax({ @@ -48,6 +46,7 @@ }), success: function(response, textStatus, jqXHR) { if (jqXHR.status === 204) { + localStorage.setItem('sso:bootstrapped', "true"); window.location.reload(); } }, @@ -59,6 +58,10 @@ const response = JSON.parse(responseText || 'false'); + if (status === 404 || (response && ['invalid_token', 'invalid_grant'].includes(response.code))) { + localStorage.setItem('sso:bootstrapped', "true"); + } + console.error('OIDC session bootstrap failed:', { error, status, From 624b42c656f801e458c8325e522c8588154688fc Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 19:42:02 -0300 Subject: [PATCH 25/26] chore: Remove payload as is not needed --- .../templates/Layout/Includes/Page_LoggedInVerification.ss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss index 74c7a26fea..9a031cb308 100644 --- a/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss +++ b/themes/openstack/templates/Layout/Includes/Page_LoggedInVerification.ss @@ -38,12 +38,6 @@ 'X-CSRF-Token': csrfToken, }, dataType: "json", - data: JSON.stringify({ - accessTokenUpdatedAt, - accessToken, - idToken, - expiresIn, - }), success: function(response, textStatus, jqXHR) { if (jqXHR.status === 204) { localStorage.setItem('sso:bootstrapped', "true"); From 3a30e8a775c85ae5a7a388c101c9ff3b64e21382 Mon Sep 17 00:00:00 2001 From: Matias Perrone Date: Fri, 12 Sep 2025 19:42:55 -0300 Subject: [PATCH 26/26] chore: Remove body checks as is not used anywhere --- .../restfull_api/OIDCSessionBootstrapApi.php | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php index 79fee544ae..381ee3d2a0 100644 --- a/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php +++ b/openstackid/code/interfaces/restfull_api/OIDCSessionBootstrapApi.php @@ -87,8 +87,7 @@ public function index(SS_HTTPRequest $request) if ($response instanceof SS_HTTPResponse) { return $response; } - $data = &$response['data']; - $accessToken = &$response['accessToken']; + $accessToken = &$response; try { $tokenData = $this->doIntrospectionRequest($accessToken); @@ -159,13 +158,6 @@ private function validateRequestData(SS_HTTPRequest $request) if (!$request->isPOST()) { return $this->methodNotAllowed(); } - - // Check Content-Type header - $contentType = $request->getHeader('Content-Type'); - if (strpos($contentType, 'application/json') === false) { - return $this->badRequest('Content-Type must be application/json'); - } - // Check Authorization header $authHeader = $request->getHeader('Authorization'); $accessToken = str_replace('Bearer ', '', $authHeader ?: ''); @@ -233,16 +225,7 @@ private function validateRequestData(SS_HTTPRequest $request) return $this->badRequest('same-origin check failed: Origin and Referer headers must have the same origin'); } - // Get JSON payload - $data = $this->getJsonRequest(); - if (!$data) { - return $this->badRequest('Invalid JSON payload'); - } - - return [ - 'data' => $data, - 'accessToken' => $accessToken - ]; + return $accessToken; } /**