feat(auth): add microsoft auto-remember policy with tenant override and configurable remember TTL

This commit is contained in:
2026-03-10 22:48:10 +01:00
parent f403a6704f
commit 964c07a9de
32 changed files with 1267 additions and 17 deletions

View File

@@ -186,6 +186,28 @@ class TenantAuthorizationPolicyTest extends TestCase
$this->assertForbiddenDecision($decision);
}
public function testEditSubmitDeniesAutoRememberModeWithoutSsoPermission(): void
{
$permissionGateway = $this->permissionGatewayAllowing([
11 => [PermissionService::TENANTS_VIEW, PermissionService::TENANTS_UPDATE],
]);
$scopeGateway = $this->createMock(DirectoryScopeGateway::class);
$scopeGateway->expects($this->once())
->method('canAccess')
->with('tenants', 22, 11)
->willReturn(true);
$policy = new TenantAuthorizationPolicy($permissionGateway, $scopeGateway);
$decision = $policy->authorize(TenantAuthorizationPolicy::ABILITY_ADMIN_TENANTS_EDIT_SUBMIT, [
'actor_user_id' => 11,
'target_tenant_id' => 22,
'input' => ['microsoft_auto_remember_mode' => '1'],
]);
$this->assertForbiddenDecision($decision);
}
public function testCustomFieldsManageRequiresPermissionAndScope(): void
{
$permissionGateway = $this->permissionGatewayAllowing([

View File

@@ -10,6 +10,7 @@ use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Auth\AuthPermissionGateway;
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
use MintyPHP\Service\Auth\AuthSettingsGateway;
use MintyPHP\Service\Auth\RememberMeService;
use PHPUnit\Framework\TestCase;
@@ -427,6 +428,81 @@ class RememberMeServiceTest extends TestCase
$this->assertSame(15, $service->expireAllTokensByAdmin());
}
// ---------------------------------------------------------------
// Dynamic TTL via AuthSettingsGateway
// ---------------------------------------------------------------
public function testRememberUserUsesConfiguredTtlFromGateway(): void
{
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getRememberTokenLifetimeDays')->willReturn(7);
$capturedExpiresAt = '';
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())
->method('create')
->with(
42,
$this->anything(),
$this->anything(),
$this->callback(function (string $e) use (&$capturedExpiresAt): bool {
$capturedExpiresAt = $e;
return strtotime($e . ' UTC') > time();
})
)
->willReturn(1);
$cookieStore = $this->createMock(CookieStoreInterface::class);
$cookieStore->expects($this->once())
->method('set')
->with(
'remember',
$this->anything(),
$this->callback(fn (array $opts): bool => $opts['expires'] > time()
&& $opts['expires'] <= time() + (7 * 86400) + 10)
);
$service = $this->newService(
tokenRepository: $tokenRepo,
cookieStore: $cookieStore,
authSettingsGateway: $settingsGateway
);
$service->rememberUser(42);
// Verify expiration is ~7 days from now (not 30)
$expiresTimestamp = strtotime($capturedExpiresAt . ' UTC');
$this->assertGreaterThan(time() + (6 * 86400), $expiresTimestamp);
$this->assertLessThanOrEqual(time() + (7 * 86400) + 10, $expiresTimestamp);
}
public function testRememberUserFallsBackTo30DaysWhenNoGateway(): void
{
$capturedExpiresAt = '';
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())
->method('create')
->with(
42,
$this->anything(),
$this->anything(),
$this->callback(function (string $e) use (&$capturedExpiresAt): bool {
$capturedExpiresAt = $e;
return true;
})
)
->willReturn(1);
$cookieStore = $this->createMock(CookieStoreInterface::class);
$cookieStore->method('set');
$service = $this->newService(tokenRepository: $tokenRepo, cookieStore: $cookieStore);
$service->rememberUser(42);
$expiresTimestamp = strtotime($capturedExpiresAt . ' UTC');
$this->assertGreaterThan(time() + (29 * 86400), $expiresTimestamp);
$this->assertLessThanOrEqual(time() + (30 * 86400) + 10, $expiresTimestamp);
}
// ---------------------------------------------------------------
// Helper
// ---------------------------------------------------------------
@@ -439,7 +515,8 @@ class RememberMeServiceTest extends TestCase
?AuthSessionTenantContextService $authSessionTenantContextService = null,
?SessionStoreInterface $sessionStore = null,
?CookieStoreInterface $cookieStore = null,
?RequestRuntimeInterface $requestRuntime = null
?RequestRuntimeInterface $requestRuntime = null,
?AuthSettingsGateway $authSettingsGateway = null
): RememberMeService {
// RequestRuntimeInterface has a method named "method()" which PHPUnit 12
// cannot mock (reserved name). Use a simple anonymous-class stub instead.
@@ -483,7 +560,8 @@ class RememberMeServiceTest extends TestCase
$authSessionTenantContextService ?? $this->createMock(AuthSessionTenantContextService::class),
$sessionStore ?? $this->createMock(SessionStoreInterface::class),
$cookieStore ?? $this->createMock(CookieStoreInterface::class),
$requestRuntime
$requestRuntime,
$authSettingsGateway
);
}
}

View File

@@ -278,6 +278,144 @@ class TenantSsoServiceTest extends TestCase
$this->assertTrue($result['ok']);
}
// ---------------------------------------------------------------
// shouldAutoRememberOnMicrosoftLogin — policy resolution
// ---------------------------------------------------------------
public function testShouldAutoRememberReturnsTrueWhenForceOn(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'auto_remember_mode' => 1,
]);
$service = $this->newService($repository);
$this->assertTrue($service->shouldAutoRememberOnMicrosoftLogin(5));
}
public function testShouldAutoRememberReturnsFalseWhenForceOff(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'auto_remember_mode' => 0,
]);
$service = $this->newService($repository);
$this->assertFalse($service->shouldAutoRememberOnMicrosoftLogin(5));
}
public function testShouldAutoRememberInheritUsesGlobalDefaultTrue(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'auto_remember_mode' => null,
]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('');
$settingsGateway->method('getMicrosoftAuthority')->willReturn('');
$settingsGateway->method('isMicrosoftAutoRememberDefault')->willReturn(true);
$service = $this->newService($repository, null, $settingsGateway);
$this->assertTrue($service->shouldAutoRememberOnMicrosoftLogin(5));
}
public function testShouldAutoRememberInheritUsesGlobalDefaultFalse(): void
{
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([
'auto_remember_mode' => null,
]);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('');
$settingsGateway->method('getMicrosoftAuthority')->willReturn('');
$settingsGateway->method('isMicrosoftAutoRememberDefault')->willReturn(false);
$service = $this->newService($repository, null, $settingsGateway);
$this->assertFalse($service->shouldAutoRememberOnMicrosoftLogin(5));
}
public function testSaveTenantMicrosoftAuthPersistsAutoRememberMode(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([]);
$repository->expects($this->once())
->method('upsertByTenantId')
->with(
5,
$this->callback(static function (array $data): bool {
return $data['auto_remember_mode'] === 1;
})
)
->willReturn(true);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('client-secret');
$settingsGateway->method('getMicrosoftAuthority')->willReturn('');
$service = $this->newService($repository, $tenantGateway, $settingsGateway);
$result = $service->saveTenantMicrosoftAuth(5, [
'microsoft_enabled' => '1',
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'use_shared_app' => '1',
'sync_profile_on_login' => '',
'sync_profile_fields' => [],
'allowed_domains' => '',
'enforce_microsoft_login' => '',
'client_id_override' => '',
'client_secret_override' => '',
'microsoft_auto_remember_mode' => '1',
]);
$this->assertTrue($result['ok']);
}
public function testSaveTenantMicrosoftAuthPersistsInheritAsNull(): void
{
$tenantGateway = $this->createMock(AuthTenantGateway::class);
$tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']);
$repository = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class);
$repository->method('findByTenantId')->willReturn([]);
$repository->expects($this->once())
->method('upsertByTenantId')
->with(
5,
$this->callback(static function (array $data): bool {
return $data['auto_remember_mode'] === null;
})
)
->willReturn(true);
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
$settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id');
$settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('client-secret');
$settingsGateway->method('getMicrosoftAuthority')->willReturn('');
$service = $this->newService($repository, $tenantGateway, $settingsGateway);
$result = $service->saveTenantMicrosoftAuth(5, [
'microsoft_enabled' => '1',
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'use_shared_app' => '1',
'sync_profile_on_login' => '',
'sync_profile_fields' => [],
'allowed_domains' => '',
'enforce_microsoft_login' => '',
'client_id_override' => '',
'client_secret_override' => '',
'microsoft_auto_remember_mode' => 'inherit',
]);
$this->assertTrue($result['ok']);
}
private function newService(
?TenantMicrosoftAuthRepositoryInterface $repository = null,
?AuthTenantGateway $tenantGateway = null,

View File

@@ -13,6 +13,7 @@ use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
use MintyPHP\Service\Settings\SettingsAppGateway;
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
use MintyPHP\Service\Settings\SettingsSessionGateway;
@@ -112,6 +113,74 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase
$this->assertContains('session_absolute_timeout_invalid', $errorKeys);
}
// ---------------------------------------------------------------
// Login persistence settings
// ---------------------------------------------------------------
public function testBuildPageDataIncludesLoginPersistenceValues(): void
{
$loginPersistenceGateway = $this->createConfiguredMock(SettingsLoginPersistenceGateway::class, [
'isMicrosoftAutoRememberDefault' => true,
'getRememberTokenLifetimeDays' => 14,
]);
$service = $this->newService(loginPersistenceGateway: $loginPersistenceGateway);
$data = $service->buildPageData();
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
$this->assertTrue((bool) ($values['microsoft_auto_remember_default'] ?? false));
$this->assertSame(14, (int) ($values['remember_token_lifetime_days'] ?? 0));
}
public function testUpdateFromAdminPersistsLoginPersistenceValues(): void
{
$loginPersistenceGateway = $this->createMock(SettingsLoginPersistenceGateway::class);
$loginPersistenceGateway->method('isMicrosoftAutoRememberDefault')->willReturn(false);
$loginPersistenceGateway->method('getRememberTokenLifetimeDays')->willReturn(30);
$loginPersistenceGateway->expects($this->once())
->method('setMicrosoftAutoRememberDefault')
->with(true);
$loginPersistenceGateway->expects($this->once())
->method('setRememberTokenLifetimeDays')
->with(14)
->willReturn(true);
$service = $this->newService(loginPersistenceGateway: $loginPersistenceGateway);
$result = $service->updateFromAdmin($this->validPostData([
'microsoft_auto_remember_default' => '1',
'remember_token_lifetime_days' => '14',
]));
$this->assertSame([], $result['errors'] ?? []);
}
public function testUpdateFromAdminReturnsErrorForInvalidRememberTtl(): void
{
$loginPersistenceGateway = $this->createMock(SettingsLoginPersistenceGateway::class);
$loginPersistenceGateway->method('isMicrosoftAutoRememberDefault')->willReturn(false);
$loginPersistenceGateway->method('getRememberTokenLifetimeDays')->willReturn(30);
$loginPersistenceGateway->method('setMicrosoftAutoRememberDefault');
$loginPersistenceGateway->expects($this->once())
->method('setRememberTokenLifetimeDays')
->with(500)
->willReturn(false);
$service = $this->newService(loginPersistenceGateway: $loginPersistenceGateway);
$result = $service->updateFromAdmin($this->validPostData([
'remember_token_lifetime_days' => '500',
]));
$errors = is_array($result['errors'] ?? null) ? $result['errors'] : [];
$errorKeys = [];
foreach ($errors as $error) {
if (is_array($error) && isset($error['key'])) {
$errorKeys[] = (string) $error['key'];
}
}
$this->assertContains('remember_token_lifetime_days_invalid', $errorKeys);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
@@ -157,7 +226,8 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase
private function newService(
?SettingsSessionGateway $settingsSessionGateway = null,
?SettingsMetadataGateway $settingsMetadataGateway = null
?SettingsMetadataGateway $settingsMetadataGateway = null,
?SettingsLoginPersistenceGateway $loginPersistenceGateway = null
): AdminSettingsService {
$settingsDefaultsGateway = $this->createConfiguredMock(SettingsDefaultsGateway::class, [
'getDefaultTenantId' => null,
@@ -239,6 +309,13 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase
'setSmtpFromName' => true,
]);
$loginPersistenceGateway = $loginPersistenceGateway ?? $this->createConfiguredMock(SettingsLoginPersistenceGateway::class, [
'isMicrosoftAutoRememberDefault' => false,
'getRememberTokenLifetimeDays' => 30,
'setMicrosoftAutoRememberDefault' => true,
'setRememberTokenLifetimeDays' => true,
]);
if ($settingsMetadataGateway === null) {
$settingsMetadataGateway = $this->createMock(SettingsMetadataGateway::class);
$settingsMetadataGateway->method('getValue')->willReturn(null);
@@ -281,6 +358,7 @@ class AdminSettingsServiceSessionPolicyTest extends TestCase
$settingsFrontendTelemetryGateway,
$settingsMicrosoftGateway,
$settingsSmtpGateway,
$loginPersistenceGateway,
$settingsMetadataGateway,
$settingCacheService,
$tenantService,

View File

@@ -0,0 +1,178 @@
<?php
namespace MintyPHP\Tests\Service\Settings;
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
use MintyPHP\Service\Settings\SettingsMetadataGateway;
use PHPUnit\Framework\TestCase;
class SettingsLoginPersistenceGatewayTest extends TestCase
{
// ---------------------------------------------------------------
// isMicrosoftAutoRememberDefault
// ---------------------------------------------------------------
public function testAutoRememberDefaultReturnsFalseWhenNotSet(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn(null);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertFalse($gateway->isMicrosoftAutoRememberDefault());
}
public function testAutoRememberDefaultReturnsFalseForZero(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn('0');
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertFalse($gateway->isMicrosoftAutoRememberDefault());
}
public function testAutoRememberDefaultReturnsTrueForOne(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn('1');
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertTrue($gateway->isMicrosoftAutoRememberDefault());
}
public function testAutoRememberDefaultReturnsFalseForEmptyString(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getValue')->willReturn('');
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertFalse($gateway->isMicrosoftAutoRememberDefault());
}
public function testSetAutoRememberDefaultPersistsValue(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->expects($this->once())
->method('set')
->with('microsoft_auto_remember_default', '1', $this->anything())
->willReturn(true);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertTrue($gateway->setMicrosoftAutoRememberDefault(true));
}
public function testSetAutoRememberDefaultPersistsFalseAsZero(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->expects($this->once())
->method('set')
->with('microsoft_auto_remember_default', '0', $this->anything())
->willReturn(true);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertTrue($gateway->setMicrosoftAutoRememberDefault(false));
}
// ---------------------------------------------------------------
// getRememberTokenLifetimeDays
// ---------------------------------------------------------------
public function testLifetimeDaysReturnsFallbackWhenNotSet(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getInt')->willReturn(null);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertSame(30, $gateway->getRememberTokenLifetimeDays());
}
public function testLifetimeDaysReturnsConfiguredValue(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getInt')->willReturn(7);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertSame(7, $gateway->getRememberTokenLifetimeDays());
}
public function testLifetimeDaysReturnsFallbackForZero(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getInt')->willReturn(0);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertSame(30, $gateway->getRememberTokenLifetimeDays());
}
public function testLifetimeDaysReturnsFallbackForValueAboveMax(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getInt')->willReturn(500);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertSame(30, $gateway->getRememberTokenLifetimeDays());
}
public function testSetLifetimeDaysAcceptsValidRange(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->expects($this->once())
->method('set')
->with('remember_token_lifetime_days', '14', $this->anything())
->willReturn(true);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertTrue($gateway->setRememberTokenLifetimeDays(14));
}
public function testSetLifetimeDaysRejectsZero(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->expects($this->never())->method('set');
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertFalse($gateway->setRememberTokenLifetimeDays(0));
}
public function testSetLifetimeDaysRejectsAboveMax(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->expects($this->never())->method('set');
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertFalse($gateway->setRememberTokenLifetimeDays(400));
}
public function testSetLifetimeDaysNullFallsBackToDefault(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->expects($this->once())
->method('set')
->with('remember_token_lifetime_days', '30', $this->anything())
->willReturn(true);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertTrue($gateway->setRememberTokenLifetimeDays(null));
}
// ---------------------------------------------------------------
// getRememberTokenLifetimeSeconds
// ---------------------------------------------------------------
public function testLifetimeSecondsComputesCorrectly(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getInt')->willReturn(7);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertSame(7 * 86400, $gateway->getRememberTokenLifetimeSeconds());
}
public function testLifetimeSecondsFallbackIs30Days(): void
{
$metadata = $this->createMock(SettingsMetadataGateway::class);
$metadata->method('getInt')->willReturn(null);
$gateway = new SettingsLoginPersistenceGateway($metadata);
$this->assertSame(30 * 86400, $gateway->getRememberTokenLifetimeSeconds());
}
}