Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
108 changes: 108 additions & 0 deletions app/Domain/Status/Controllers/Index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?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
{
/**
* Keys that must NEVER appear in the unauthenticated response, even if a
* publicStatus filter (or a future edit) adds them — a recon-risk inventory.
* Stripped after the filter runs, as defense in depth.
*/
private const SENSITIVE_KEYS = ['plugins', 'pluginVersions', 'installedPlugins', 'dbVersion', 'db_version'];

private Environment $config;

private AppSettings $appSettings;

private IncomingRequest $request;

/**
* init - inject config, app settings, and the incoming request.
*/
public function init(Environment $config, AppSettings $appSettings, IncomingRequest $request): void
{
$this->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);
}
}
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