diff --git a/app/Core/Middleware/AuthCheck.php b/app/Core/Middleware/AuthCheck.php index cbf2261524..f546721385 100644 --- a/app/Core/Middleware/AuthCheck.php +++ b/app/Core/Middleware/AuthCheck.php @@ -37,6 +37,8 @@ class AuthCheck 'oidc.login', 'oidc.callback', 'oidc.mobile', + 'status', + 'status.index', 'cron.run', 'auth.callback', 'auth.redirect', diff --git a/app/Domain/Status/Controllers/Index.php b/app/Domain/Status/Controllers/Index.php new file mode 100644 index 0000000000..2a586bee14 --- /dev/null +++ b/app/Domain/Status/Controllers/Index.php @@ -0,0 +1,108 @@ +config = $config; + $this->appSettings = $appSettings; + $this->request = $request; + } + + /** + * Return the public discovery payload: enabled auth methods, the OIDC login + * URL (when enabled), instance name, core version, and min app version — the + * safe unauthenticated tier only. Never plugin inventory / versions / db + * version (see the class-level security note). + */ + public function get(array $params): Response + { + $oidcEnabled = (bool) $this->config->oidcEnable; + $ldapEnabled = $this->config->useLdap === true && extension_loaded('ldap'); + + // password is always available; ldap/oidc only when configured. + $authMethods = ['password']; + if ($ldapEnabled) { + $authMethods[] = 'ldap'; + } + if ($oidcEnabled) { + $authMethods[] = 'oidc'; + } + + $payload = [ + 'mobileAuthEnabled' => true, + 'instanceName' => (string) ($this->config->sitename ?: 'Leantime'), + 'version' => $this->appSettings->appVersion, + 'minAppVersion' => null, + 'authMethods' => $authMethods, + 'ssoProviders' => [], + ]; + + if ($oidcEnabled) { + // Generic-OIDC login initiation URL (core). The app opens this in the + // system auth browser; the mobile branch is triggered by its own query + // params (see Oidc\Controllers\Login). + $payload['oidcLoginUrl'] = $this->request->getSchemeAndHttpHost().'/oidc/login'; + } + + // AdvancedAuth (or other plugins) can append named SSO providers to the + // PUBLIC-safe payload (labels + login URLs only) via this filter, without + // core knowing about them. Filter handlers MUST preserve the public-safe + // contract — never add secrets, plugin inventory, or versions here. + $payload = self::dispatchFilter('publicStatus', $payload, ['request' => $this->request]); + + // Defense in depth: this endpoint is unauthenticated, so strip any + // known-sensitive keys a misbehaving filter (or a future edit) might have + // added. The recon-risk inventory must NEVER reach an unauthenticated + // caller, even if a plugin gets the contract wrong. + foreach (self::SENSITIVE_KEYS as $sensitive) { + unset($payload[$sensitive]); + } + + return new JsonResponse($payload); + } +} diff --git a/tests/Unit/app/Core/Middleware/AuthCheckTest.php b/tests/Unit/app/Core/Middleware/AuthCheckTest.php index 724c323102..5a0e47628e 100644 --- a/tests/Unit/app/Core/Middleware/AuthCheckTest.php +++ b/tests/Unit/app/Core/Middleware/AuthCheckTest.php @@ -105,4 +105,14 @@ public function test_oidc_mobile_exchange_is_a_public_route(): void // Negative control: an oidc sub-route that is NOT allow-listed stays private. $this->assertFalse($authCheck->isPublicController('oidc.settings.save')); } + + public function test_status_discovery_is_a_public_route(): void + { + $authCheck = $this->make(AuthCheck::class); + + // The mobile app hits /status unauthenticated at connect time to discover + // login methods, so the route must be public. + $this->assertTrue($authCheck->isPublicController('status.index')); + $this->assertTrue($authCheck->isPublicController('status')); + } } diff --git a/tests/Unit/app/Domain/Status/Controllers/IndexTest.php b/tests/Unit/app/Domain/Status/Controllers/IndexTest.php new file mode 100644 index 0000000000..615d444c07 --- /dev/null +++ b/tests/Unit/app/Domain/Status/Controllers/IndexTest.php @@ -0,0 +1,90 @@ +app = new Application(APP_ROOT); + $this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]); + $this->app->boot(); + $this->app['view'] = $this->createMock(\Illuminate\View\Factory::class); + $this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class); + $this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class)); + } + + private function makeController(array $overrides): Index + { + // Environment's constructor overwrites known config keys with + // env-resolved defaults, so set the values AFTER construction. + $env = new Environment; + $env->set('oidcEnable', $overrides['oidcEnable'] ?? false); + $env->set('useLdap', $overrides['useLdap'] ?? false); + $env->set('sitename', $overrides['sitename'] ?? 'Leantime'); + + $request = IncomingRequest::create('https://demo.leantime.io/status', 'GET'); + $this->app->instance(IncomingRequest::class, $request); + $this->app->instance(Environment::class, $env); + $this->app->instance(AppSettings::class, new AppSettings); + + return new Index($request, $this->createMock(Template::class), $this->createMock(Language::class)); + } + + private function bodyOf($response): array + { + return json_decode($response->getContent(), true); + } + + public function test_password_only_when_no_sso_configured(): void + { + $response = $this->makeController(['oidcEnable' => false, 'useLdap' => false, 'sitename' => 'Acme'])->get([]); + $body = $this->bodyOf($response); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['password'], $body['authMethods']); + $this->assertArrayNotHasKey('oidcLoginUrl', $body); + $this->assertSame('Acme', $body['instanceName']); + $this->assertTrue($body['mobileAuthEnabled']); + } + + public function test_oidc_enabled_advertises_oidc_and_login_url(): void + { + $response = $this->makeController(['oidcEnable' => true, 'useLdap' => false, 'sitename' => 'Acme'])->get([]); + $body = $this->bodyOf($response); + + $this->assertContains('oidc', $body['authMethods']); + $this->assertSame('https://demo.leantime.io/oidc/login', $body['oidcLoginUrl']); + } + + public function test_response_never_leaks_a_plugin_or_version_inventory(): void + { + // The unauthenticated tier must not become a recon gift. + $response = $this->makeController(['oidcEnable' => true, 'useLdap' => false])->get([]); + $body = $this->bodyOf($response); + + $this->assertArrayNotHasKey('plugins', $body); + $this->assertArrayNotHasKey('dbVersion', $body); + $this->assertArrayHasKey('version', $body); + } +}