Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/Core/Middleware/AuthCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class AuthCheck
'oidc.login',
'oidc.callback',
'oidc.mobile',
'status',
'status.index',
'cron.run',
'auth.callback',
'auth.redirect',
Expand Down
84 changes: 84 additions & 0 deletions app/Domain/Status/Controllers/Index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace Leantime\Domain\Status\Controllers;

use Leantime\Core\Configuration\AppSettings;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Http\IncomingRequest;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

/**
* Public, unauthenticated instance status / discovery endpoint — GET /status.
*
* The mobile app calls this at connect time to discover which login methods the
* instance offers (password / ldap / oidc), so it can show the right affordances
* — notably the OIDC "Sign in with SSO" redirect, which stays dormant in the app
* until a backend advertises it here. See the mobile client's
* connectionService::fetchPublicStatus and
* docs/backend-mobile-auth-bridge-plan.md for the contract.
*
* SECURITY — this endpoint is UNAUTHENTICATED, so it returns ONLY the safe,
* minimal tier: auth methods, the core version + instance name, provider labels,
* and the min app version. It deliberately does NOT list installed plugins,
* plugin versions, or the db version — an unauthenticated inventory of those is a
* recon gift (CVE matching). Those stay behind auth (the authenticated
* mobileStatus). Keep any `publicStatus` filter additions to this safe tier.
*
* Route 'status.index' is allow-listed public in AuthCheck.
*/
class Index extends Controller
{
private Environment $config;

private AppSettings $appSettings;

private IncomingRequest $request;

public function init(Environment $config, AppSettings $appSettings, IncomingRequest $request): void
{
$this->config = $config;
$this->appSettings = $appSettings;
$this->request = $request;
}

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]);

return new JsonResponse($payload);
Comment on lines +96 to +106
}
}
10 changes: 10 additions & 0 deletions tests/Unit/app/Core/Middleware/AuthCheckTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
90 changes: 90 additions & 0 deletions tests/Unit/app/Domain/Status/Controllers/IndexTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace Tests\Unit\app\Domain\Status\Controllers;

use Leantime\Core\Application;
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
use Leantime\Core\Bootstrap\LoadConfig;
use Leantime\Core\Bootstrap\SetRequestForConsole;
use Leantime\Core\Configuration\AppSettings;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Http\IncomingRequest;
use Leantime\Core\Language;
use Leantime\Core\UI\Template;
use Leantime\Domain\Status\Controllers\Index;

/**
* Unit tests for the public /status discovery endpoint.
*
* Pins the contract the mobile app relies on (authMethods + oidcLoginUrl drive
* whether the SSO button appears) AND the security tier: the unauthenticated
* response must NEVER leak a plugin/version inventory.
*/
class IndexTest extends \Unit\TestCase
{
protected function setUp(): void
{
parent::setUp();

$this->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);
}
}
Loading