Skip to content

Commit 5eee264

Browse files
feat(status): public /status discovery endpoint — advertise auth methods for mobile SSO (#3664)
* feat(status): public /status discovery endpoint — advertise auth methods for mobile SSO The mobile app calls GET /status (unauthenticated) at connect time to discover which login methods an instance offers, so it can show the right affordances — notably the OIDC 'Sign in with SSO' flow, which stays DORMANT in the app until a backend advertises it here. This is the discovery half of the mobile OIDC bridge (#3637); without it the SSO button never appears even when the bridge is deployed. - app/Domain/Status/Controllers/Index.php — GET /status returns the SAFE public tier: authMethods (password / ldap / oidc, from config), oidcLoginUrl when OIDC is enabled, instanceName, core version, minAppVersion, and an empty ssoProviders that AdvancedAuth can populate via the 'publicStatus' filter. Deliberately OMITS plugin inventory / versions / dbVersion — unauthenticated that is a recon gift (CVE matching); those stay behind auth (the authenticated mobileStatus). - AuthCheck: allow-list 'status' / 'status.index' as public. - Tests: IndexTest (password-only, oidc-advertised + login URL, no-inventory-leak) + AuthCheck public-route test. Pint clean. Answers Q5 in docs/backend-mobile-auth-bridge-plan.md. Follow-up to #3637/#3662/#3663. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR * fix(status): address #3664 Copilot review — strip sensitive keys post-filter + docblocks - Defense in depth: after the publicStatus filter runs, strip a denylist of known-sensitive keys (plugins / plugin versions / dbVersion) so a misbehaving filter (or a future edit) can't leak the recon-risk inventory into this unauthenticated response, even if a plugin gets the public-safe contract wrong. - Add init()/get() method docblocks to match the controller doc style. IndexTest green (3 tests / 10 assertions); Pint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 51ed201 commit 5eee264

4 files changed

Lines changed: 210 additions & 0 deletions

File tree

app/Core/Middleware/AuthCheck.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class AuthCheck
3737
'oidc.login',
3838
'oidc.callback',
3939
'oidc.mobile',
40+
'status',
41+
'status.index',
4042
'cron.run',
4143
'auth.callback',
4244
'auth.redirect',
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace Leantime\Domain\Status\Controllers;
4+
5+
use Leantime\Core\Configuration\AppSettings;
6+
use Leantime\Core\Configuration\Environment;
7+
use Leantime\Core\Controller\Controller;
8+
use Leantime\Core\Http\IncomingRequest;
9+
use Symfony\Component\HttpFoundation\JsonResponse;
10+
use Symfony\Component\HttpFoundation\Response;
11+
12+
/**
13+
* Public, unauthenticated instance status / discovery endpoint — GET /status.
14+
*
15+
* The mobile app calls this at connect time to discover which login methods the
16+
* instance offers (password / ldap / oidc), so it can show the right affordances
17+
* — notably the OIDC "Sign in with SSO" redirect, which stays dormant in the app
18+
* until a backend advertises it here. See the mobile client's
19+
* connectionService::fetchPublicStatus and
20+
* docs/backend-mobile-auth-bridge-plan.md for the contract.
21+
*
22+
* SECURITY — this endpoint is UNAUTHENTICATED, so it returns ONLY the safe,
23+
* minimal tier: auth methods, the core version + instance name, provider labels,
24+
* and the min app version. It deliberately does NOT list installed plugins,
25+
* plugin versions, or the db version — an unauthenticated inventory of those is a
26+
* recon gift (CVE matching). Those stay behind auth (the authenticated
27+
* mobileStatus). Keep any `publicStatus` filter additions to this safe tier.
28+
*
29+
* Route 'status.index' is allow-listed public in AuthCheck.
30+
*/
31+
class Index extends Controller
32+
{
33+
/**
34+
* Keys that must NEVER appear in the unauthenticated response, even if a
35+
* publicStatus filter (or a future edit) adds them — a recon-risk inventory.
36+
* Stripped after the filter runs, as defense in depth.
37+
*/
38+
private const SENSITIVE_KEYS = ['plugins', 'pluginVersions', 'installedPlugins', 'dbVersion', 'db_version'];
39+
40+
private Environment $config;
41+
42+
private AppSettings $appSettings;
43+
44+
private IncomingRequest $request;
45+
46+
/**
47+
* init - inject config, app settings, and the incoming request.
48+
*/
49+
public function init(Environment $config, AppSettings $appSettings, IncomingRequest $request): void
50+
{
51+
$this->config = $config;
52+
$this->appSettings = $appSettings;
53+
$this->request = $request;
54+
}
55+
56+
/**
57+
* Return the public discovery payload: enabled auth methods, the OIDC login
58+
* URL (when enabled), instance name, core version, and min app version — the
59+
* safe unauthenticated tier only. Never plugin inventory / versions / db
60+
* version (see the class-level security note).
61+
*/
62+
public function get(array $params): Response
63+
{
64+
$oidcEnabled = (bool) $this->config->oidcEnable;
65+
$ldapEnabled = $this->config->useLdap === true && extension_loaded('ldap');
66+
67+
// password is always available; ldap/oidc only when configured.
68+
$authMethods = ['password'];
69+
if ($ldapEnabled) {
70+
$authMethods[] = 'ldap';
71+
}
72+
if ($oidcEnabled) {
73+
$authMethods[] = 'oidc';
74+
}
75+
76+
$payload = [
77+
'mobileAuthEnabled' => true,
78+
'instanceName' => (string) ($this->config->sitename ?: 'Leantime'),
79+
'version' => $this->appSettings->appVersion,
80+
'minAppVersion' => null,
81+
'authMethods' => $authMethods,
82+
'ssoProviders' => [],
83+
];
84+
85+
if ($oidcEnabled) {
86+
// Generic-OIDC login initiation URL (core). The app opens this in the
87+
// system auth browser; the mobile branch is triggered by its own query
88+
// params (see Oidc\Controllers\Login).
89+
$payload['oidcLoginUrl'] = $this->request->getSchemeAndHttpHost().'/oidc/login';
90+
}
91+
92+
// AdvancedAuth (or other plugins) can append named SSO providers to the
93+
// PUBLIC-safe payload (labels + login URLs only) via this filter, without
94+
// core knowing about them. Filter handlers MUST preserve the public-safe
95+
// contract — never add secrets, plugin inventory, or versions here.
96+
$payload = self::dispatchFilter('publicStatus', $payload, ['request' => $this->request]);
97+
98+
// Defense in depth: this endpoint is unauthenticated, so strip any
99+
// known-sensitive keys a misbehaving filter (or a future edit) might have
100+
// added. The recon-risk inventory must NEVER reach an unauthenticated
101+
// caller, even if a plugin gets the contract wrong.
102+
foreach (self::SENSITIVE_KEYS as $sensitive) {
103+
unset($payload[$sensitive]);
104+
}
105+
106+
return new JsonResponse($payload);
107+
}
108+
}

tests/Unit/app/Core/Middleware/AuthCheckTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,14 @@ public function test_oidc_mobile_exchange_is_a_public_route(): void
105105
// Negative control: an oidc sub-route that is NOT allow-listed stays private.
106106
$this->assertFalse($authCheck->isPublicController('oidc.settings.save'));
107107
}
108+
109+
public function test_status_discovery_is_a_public_route(): void
110+
{
111+
$authCheck = $this->make(AuthCheck::class);
112+
113+
// The mobile app hits /status unauthenticated at connect time to discover
114+
// login methods, so the route must be public.
115+
$this->assertTrue($authCheck->isPublicController('status.index'));
116+
$this->assertTrue($authCheck->isPublicController('status'));
117+
}
108118
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Tests\Unit\app\Domain\Status\Controllers;
4+
5+
use Leantime\Core\Application;
6+
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
7+
use Leantime\Core\Bootstrap\LoadConfig;
8+
use Leantime\Core\Bootstrap\SetRequestForConsole;
9+
use Leantime\Core\Configuration\AppSettings;
10+
use Leantime\Core\Configuration\Environment;
11+
use Leantime\Core\Http\IncomingRequest;
12+
use Leantime\Core\Language;
13+
use Leantime\Core\UI\Template;
14+
use Leantime\Domain\Status\Controllers\Index;
15+
16+
/**
17+
* Unit tests for the public /status discovery endpoint.
18+
*
19+
* Pins the contract the mobile app relies on (authMethods + oidcLoginUrl drive
20+
* whether the SSO button appears) AND the security tier: the unauthenticated
21+
* response must NEVER leak a plugin/version inventory.
22+
*/
23+
class IndexTest extends \Unit\TestCase
24+
{
25+
protected function setUp(): void
26+
{
27+
parent::setUp();
28+
29+
$this->app = new Application(APP_ROOT);
30+
$this->app->bootstrapWith([LoadConfig::class, SetRequestForConsole::class]);
31+
$this->app->boot();
32+
$this->app['view'] = $this->createMock(\Illuminate\View\Factory::class);
33+
$this->app['session'] = $this->createMock(\Illuminate\Session\SessionManager::class);
34+
$this->app->instance(PermissionEnforcer::class, $this->createMock(PermissionEnforcer::class));
35+
}
36+
37+
private function makeController(array $overrides): Index
38+
{
39+
// Environment's constructor overwrites known config keys with
40+
// env-resolved defaults, so set the values AFTER construction.
41+
$env = new Environment;
42+
$env->set('oidcEnable', $overrides['oidcEnable'] ?? false);
43+
$env->set('useLdap', $overrides['useLdap'] ?? false);
44+
$env->set('sitename', $overrides['sitename'] ?? 'Leantime');
45+
46+
$request = IncomingRequest::create('https://demo.leantime.io/status', 'GET');
47+
$this->app->instance(IncomingRequest::class, $request);
48+
$this->app->instance(Environment::class, $env);
49+
$this->app->instance(AppSettings::class, new AppSettings);
50+
51+
return new Index($request, $this->createMock(Template::class), $this->createMock(Language::class));
52+
}
53+
54+
private function bodyOf($response): array
55+
{
56+
return json_decode($response->getContent(), true);
57+
}
58+
59+
public function test_password_only_when_no_sso_configured(): void
60+
{
61+
$response = $this->makeController(['oidcEnable' => false, 'useLdap' => false, 'sitename' => 'Acme'])->get([]);
62+
$body = $this->bodyOf($response);
63+
64+
$this->assertSame(200, $response->getStatusCode());
65+
$this->assertSame(['password'], $body['authMethods']);
66+
$this->assertArrayNotHasKey('oidcLoginUrl', $body);
67+
$this->assertSame('Acme', $body['instanceName']);
68+
$this->assertTrue($body['mobileAuthEnabled']);
69+
}
70+
71+
public function test_oidc_enabled_advertises_oidc_and_login_url(): void
72+
{
73+
$response = $this->makeController(['oidcEnable' => true, 'useLdap' => false, 'sitename' => 'Acme'])->get([]);
74+
$body = $this->bodyOf($response);
75+
76+
$this->assertContains('oidc', $body['authMethods']);
77+
$this->assertSame('https://demo.leantime.io/oidc/login', $body['oidcLoginUrl']);
78+
}
79+
80+
public function test_response_never_leaks_a_plugin_or_version_inventory(): void
81+
{
82+
// The unauthenticated tier must not become a recon gift.
83+
$response = $this->makeController(['oidcEnable' => true, 'useLdap' => false])->get([]);
84+
$body = $this->bodyOf($response);
85+
86+
$this->assertArrayNotHasKey('plugins', $body);
87+
$this->assertArrayNotHasKey('dbVersion', $body);
88+
$this->assertArrayHasKey('version', $body);
89+
}
90+
}

0 commit comments

Comments
 (0)