Skip to content

Commit acfdcf8

Browse files
dereuromarkclaude
andcommitted
Fix loadIdentifier called after loadAuthenticator losing resolver config
When loadAuthenticator() was called before loadIdentifier(), the authenticator would receive an empty IdentifierCollection and create its own default Password identifier immediately in the constructor. Later calls to loadIdentifier() would add to the service's identifier collection, but the authenticator already had its own separate collection with the default identifier. This fix changes the default identifier loading from eager (in constructor) to lazy (in authenticate()). This ensures that if loadIdentifier() is called after loadAuthenticator(), the identifier will be loaded into the shared collection before the authenticator tries to use it. Fixes #754 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d7889fa commit acfdcf8

File tree

7 files changed

+149
-71
lines changed

7 files changed

+149
-71
lines changed

src/Authenticator/CookieAuthenticator.php

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,26 +58,23 @@ class CookieAuthenticator extends AbstractAuthenticator implements PersistenceIn
5858
];
5959

6060
/**
61-
* Constructor
61+
* Gets the identifier, loading a default Password identifier if none configured.
6262
*
63-
* @param \Authentication\Identifier\IdentifierInterface $identifier Identifier or identifiers collection.
64-
* @param array<string, mixed> $config Configuration settings.
63+
* This is done lazily to allow loadIdentifier() to be called after loadAuthenticator().
64+
*
65+
* @return \Authentication\Identifier\IdentifierInterface
6566
*/
66-
public function __construct(IdentifierInterface $identifier, array $config = [])
67+
public function getIdentifier(): IdentifierInterface
6768
{
68-
// If no identifier is configured, set up a default Password identifier
69-
if ($identifier instanceof IdentifierCollection && $identifier->isEmpty()) {
70-
// Pass the authenticator's fields configuration to the identifier
69+
if ($this->_identifier instanceof IdentifierCollection && $this->_identifier->isEmpty()) {
7170
$identifierConfig = [];
72-
if (isset($config['fields'])) {
73-
$identifierConfig['fields'] = $config['fields'];
71+
if ($this->getConfig('fields')) {
72+
$identifierConfig['fields'] = $this->getConfig('fields');
7473
}
75-
$identifier = new IdentifierCollection([
76-
'Authentication.Password' => $identifierConfig,
77-
]);
74+
$this->_identifier->load('Authentication.Password', $identifierConfig);
7875
}
7976

80-
parent::__construct($identifier, $config);
77+
return $this->_identifier;
8178
}
8279

8380
/**
@@ -107,10 +104,11 @@ public function authenticate(ServerRequestInterface $request): ResultInterface
107104

108105
[$username, $tokenHash] = $token;
109106

110-
$identity = $this->_identifier->identify(compact('username'));
107+
$identifier = $this->getIdentifier();
108+
$identity = $identifier->identify(compact('username'));
111109

112110
if (!$identity) {
113-
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $this->_identifier->getErrors());
111+
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $identifier->getErrors());
114112
}
115113

116114
if (!$this->_checkToken($identity, $tokenHash)) {

src/Authenticator/FormAuthenticator.php

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,23 @@ class FormAuthenticator extends AbstractAuthenticator
5050
];
5151

5252
/**
53-
* Constructor
53+
* Gets the identifier, loading a default Password identifier if none configured.
5454
*
55-
* @param \Authentication\Identifier\IdentifierInterface $identifier Identifier or identifiers collection.
56-
* @param array<string, mixed> $config Configuration settings.
55+
* This is done lazily to allow loadIdentifier() to be called after loadAuthenticator().
56+
*
57+
* @return \Authentication\Identifier\IdentifierInterface
5758
*/
58-
public function __construct(IdentifierInterface $identifier, array $config = [])
59+
public function getIdentifier(): IdentifierInterface
5960
{
60-
// If no identifier is configured, set up a default Password identifier
61-
if ($identifier instanceof IdentifierCollection && $identifier->isEmpty()) {
62-
// Pass the authenticator's fields configuration to the identifier
61+
if ($this->_identifier instanceof IdentifierCollection && $this->_identifier->isEmpty()) {
6362
$identifierConfig = [];
64-
if (isset($config['fields'])) {
65-
$identifierConfig['fields'] = $config['fields'];
63+
if ($this->getConfig('fields')) {
64+
$identifierConfig['fields'] = $this->getConfig('fields');
6665
}
67-
$identifier = new IdentifierCollection([
68-
'Authentication.Password' => $identifierConfig,
69-
]);
66+
$this->_identifier->load('Authentication.Password', $identifierConfig);
7067
}
7168

72-
parent::__construct($identifier, $config);
69+
return $this->_identifier;
7370
}
7471

7572
/**
@@ -141,9 +138,12 @@ protected function _buildLoginUrlErrorResult(ServerRequestInterface $request): R
141138
}
142139

143140
/**
144-
* Authenticates the identity contained in a request. Will use the `config.userModel`, and `config.fields`
145-
* to find POST data that is used to find a matching record in the `config.userModel`. Will return false if
146-
* there is no post data, either username or password is missing, or if the scope conditions have not been met.
141+
* Authenticates the identity contained in a request.
142+
*
143+
* Will use the `config.userModel`, and `config.fields` to find POST data
144+
* that is used to find a matching record in the `config.userModel`.
145+
* Will return false if there is no post data, either username or password is missing,
146+
* or if the scope conditions have not been met.
147147
*
148148
* @param \Psr\Http\Message\ServerRequestInterface $request The request that contains login information.
149149
* @return \Authentication\Authenticator\ResultInterface
@@ -161,10 +161,11 @@ public function authenticate(ServerRequestInterface $request): ResultInterface
161161
]);
162162
}
163163

164-
$user = $this->_identifier->identify($data);
164+
$identifier = $this->getIdentifier();
165+
$user = $identifier->identify($data);
165166

166167
if (!$user) {
167-
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $this->_identifier->getErrors());
168+
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $identifier->getErrors());
168169
}
169170

170171
return new Result($user, Result::SUCCESS);

src/Authenticator/HttpBasicAuthenticator.php

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,23 @@ class HttpBasicAuthenticator extends AbstractAuthenticator implements StatelessI
4444
];
4545

4646
/**
47-
* Constructor
47+
* Gets the identifier, loading a default Password identifier if none configured.
4848
*
49-
* @param \Authentication\Identifier\IdentifierInterface $identifier Identifier or identifiers collection.
50-
* @param array<string, mixed> $config Configuration settings.
49+
* This is done lazily to allow loadIdentifier() to be called after loadAuthenticator().
50+
*
51+
* @return \Authentication\Identifier\IdentifierInterface
5152
*/
52-
public function __construct(IdentifierInterface $identifier, array $config = [])
53+
public function getIdentifier(): IdentifierInterface
5354
{
54-
// If no identifier is configured, set up a default Password identifier
55-
if ($identifier instanceof IdentifierCollection && $identifier->isEmpty()) {
56-
// Pass the authenticator's fields configuration to the identifier
55+
if ($this->_identifier instanceof IdentifierCollection && $this->_identifier->isEmpty()) {
5756
$identifierConfig = [];
58-
if (isset($config['fields'])) {
59-
$identifierConfig['fields'] = $config['fields'];
57+
if ($this->getConfig('fields')) {
58+
$identifierConfig['fields'] = $this->getConfig('fields');
6059
}
61-
$identifier = new IdentifierCollection([
62-
'Authentication.Password' => $identifierConfig,
63-
]);
60+
$this->_identifier->load('Authentication.Password', $identifierConfig);
6461
}
6562

66-
parent::__construct($identifier, $config);
63+
return $this->_identifier;
6764
}
6865

6966
/**
@@ -83,7 +80,7 @@ public function authenticate(ServerRequestInterface $request): ResultInterface
8380
return new Result(null, Result::FAILURE_CREDENTIALS_MISSING);
8481
}
8582

86-
$user = $this->_identifier->identify([
83+
$user = $this->getIdentifier()->identify([
8784
AbstractIdentifier::CREDENTIAL_USERNAME => $username,
8885
AbstractIdentifier::CREDENTIAL_PASSWORD => $password,
8986
]);

src/Authenticator/JwtAuthenticator.php

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,7 @@ class JwtAuthenticator extends TokenAuthenticator
5757
*/
5858
public function __construct(IdentifierInterface $identifier, array $config = [])
5959
{
60-
// Override parent's default - JWT should use JwtSubject identifier
61-
if ($identifier instanceof IdentifierCollection && $identifier->isEmpty()) {
62-
$identifier = new IdentifierCollection(['Authentication.JwtSubject']);
63-
}
64-
65-
// Call TokenAuthenticator's constructor but skip its default
66-
AbstractAuthenticator::__construct($identifier, $config);
60+
parent::__construct($identifier, $config);
6761

6862
if (empty($this->_config['secretKey'])) {
6963
if (!class_exists(Security::class)) {
@@ -73,6 +67,22 @@ public function __construct(IdentifierInterface $identifier, array $config = [])
7367
}
7468
}
7569

70+
/**
71+
* Gets the identifier, loading a default JwtSubject identifier if none configured.
72+
*
73+
* This is done lazily to allow loadIdentifier() to be called after loadAuthenticator().
74+
*
75+
* @return \Authentication\Identifier\IdentifierInterface
76+
*/
77+
public function getIdentifier(): IdentifierInterface
78+
{
79+
if ($this->_identifier instanceof IdentifierCollection && $this->_identifier->isEmpty()) {
80+
$this->_identifier->load('Authentication.JwtSubject');
81+
}
82+
83+
return $this->_identifier;
84+
}
85+
7686
/**
7787
* Authenticates the identity based on a JWT token contained in a request.
7888
*
@@ -113,12 +123,13 @@ public function authenticate(ServerRequestInterface $request): ResultInterface
113123
return new Result($user, Result::SUCCESS);
114124
}
115125

116-
$user = $this->_identifier->identify([
126+
$identifier = $this->getIdentifier();
127+
$user = $identifier->identify([
117128
$subjectKey => $result[$subjectKey],
118129
]);
119130

120131
if (!$user) {
121-
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $this->_identifier->getErrors());
132+
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $identifier->getErrors());
122133
}
123134

124135
return new Result($user, Result::SUCCESS);

src/Authenticator/TokenAuthenticator.php

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,19 @@ class TokenAuthenticator extends AbstractAuthenticator implements StatelessInter
3838
];
3939

4040
/**
41-
* Constructor
41+
* Gets the identifier, loading a default Token identifier if none configured.
4242
*
43-
* @param \Authentication\Identifier\IdentifierInterface $identifier Identifier or identifiers collection.
44-
* @param array<string, mixed> $config Configuration settings.
43+
* This is done lazily to allow loadIdentifier() to be called after loadAuthenticator().
44+
*
45+
* @return \Authentication\Identifier\IdentifierInterface
4546
*/
46-
public function __construct(IdentifierInterface $identifier, array $config = [])
47+
public function getIdentifier(): IdentifierInterface
4748
{
48-
// If no identifier is configured, set up a default Token identifier
49-
if ($identifier instanceof IdentifierCollection && $identifier->isEmpty()) {
50-
$identifier = new IdentifierCollection(['Authentication.Token']);
49+
if ($this->_identifier instanceof IdentifierCollection && $this->_identifier->isEmpty()) {
50+
$this->_identifier->load('Authentication.Token');
5151
}
5252

53-
parent::__construct($identifier, $config);
53+
return $this->_identifier;
5454
}
5555

5656
/**
@@ -142,12 +142,13 @@ public function authenticate(ServerRequestInterface $request): ResultInterface
142142
return new Result(null, Result::FAILURE_CREDENTIALS_MISSING);
143143
}
144144

145-
$user = $this->_identifier->identify([
145+
$identifier = $this->getIdentifier();
146+
$user = $identifier->identify([
146147
TokenIdentifier::CREDENTIAL_TOKEN => $token,
147148
]);
148149

149150
if (!$user) {
150-
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $this->_identifier->getErrors());
151+
return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND, $identifier->getErrors());
151152
}
152153

153154
return new Result($user, Result::SUCCESS);

tests/TestCase/AuthenticationServiceTest.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,4 +1373,63 @@ public function testFormAuthenticatorDefaultIdentifier()
13731373
$authenticator = $service->getAuthenticationProvider();
13741374
$this->assertInstanceOf(FormAuthenticator::class, $authenticator);
13751375
}
1376+
1377+
/**
1378+
* Test that loadIdentifier called after loadAuthenticator still works.
1379+
*
1380+
* This is a regression test for https://github.com/cakephp/authentication/issues/754
1381+
* When loadAuthenticator was called before loadIdentifier, the authenticator would
1382+
* create its own default identifier collection and ignore the later loadIdentifier call.
1383+
*
1384+
* @deprecated Note that this test will be removed in 4.x as this is only to keep BC in 3.x.
1385+
*
1386+
* @return void
1387+
*/
1388+
public function testLoadIdentifierAfterLoadAuthenticator()
1389+
{
1390+
$this->skipIf(
1391+
version_compare(Version::id(), '11.0', '<'),
1392+
'For some reason PHPUnit doesn\'t pick up the deprecation on v10',
1393+
);
1394+
1395+
$request = ServerRequestFactory::fromGlobals(
1396+
['REQUEST_URI' => '/testpath'],
1397+
[],
1398+
['username' => 'mariano', 'password' => 'password'],
1399+
);
1400+
1401+
$service = new AuthenticationService();
1402+
1403+
// Load authenticator FIRST
1404+
$service->loadAuthenticator('Authentication.Form');
1405+
1406+
// Then load identifier with custom resolver config
1407+
$this->deprecated(function () use ($service) {
1408+
$service->loadIdentifier('Authentication.Password', [
1409+
'resolver' => [
1410+
'className' => 'Authentication.Orm',
1411+
'userModel' => 'AuthUsers',
1412+
'finder' => 'auth',
1413+
],
1414+
]);
1415+
});
1416+
1417+
// The authenticator should use the identifier we loaded, not its default
1418+
$formAuth = $service->authenticators()->get('Form');
1419+
$identifierCollection = $formAuth->getIdentifier();
1420+
$this->assertInstanceOf(IdentifierCollection::class, $identifierCollection);
1421+
1422+
$passwordIdentifier = $identifierCollection->get('Password');
1423+
$this->assertInstanceOf(PasswordIdentifier::class, $passwordIdentifier);
1424+
1425+
// Verify the resolver config was applied
1426+
$resolverConfig = $passwordIdentifier->getConfig('resolver');
1427+
$this->assertIsArray($resolverConfig);
1428+
$this->assertSame('AuthUsers', $resolverConfig['userModel']);
1429+
$this->assertSame('auth', $resolverConfig['finder']);
1430+
1431+
// Verify authentication actually works with the custom config
1432+
$result = $service->authenticate($request);
1433+
$this->assertTrue($result->isValid());
1434+
}
13761435
}

tests/TestCase/Authenticator/FormAuthenticatorTest.php

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -641,25 +641,36 @@ public function testDefaultIdentifierInheritsFieldsConfig()
641641
$identifiers = new IdentifierCollection();
642642

643643
// Configure authenticator with custom fields mapping
644+
// Also set a loginUrl that won't match, so authenticate() returns early
645+
// without actually trying to identify (which would require database access)
644646
$config = [
645647
'fields' => [
646648
'username' => 'user_name',
647649
'password' => 'pass_word',
648650
],
651+
'loginUrl' => '/login',
649652
];
650653

651654
// FormAuthenticator should create default identifier with inherited fields
655+
// The default identifier is loaded lazily when authenticate() is called
652656
$form = new FormAuthenticator($identifiers, $config);
653657

658+
// Trigger the lazy loading by calling authenticate on a non-matching URL
659+
$request = ServerRequestFactory::fromGlobals(
660+
['REQUEST_URI' => '/testpath'],
661+
[],
662+
['user_name' => 'mariano', 'pass_word' => 'password'],
663+
);
664+
$form->authenticate($request);
665+
654666
// Verify the identifier was created with the correct configuration
655667
$identifier = $form->getIdentifier();
656668
$this->assertInstanceOf(IdentifierCollection::class, $identifier);
657669
$this->assertFalse($identifier->isEmpty());
658670

659-
// Verify the fields are properly configured
660-
// We can't directly access the internal configuration, but we can verify
661-
// the FormAuthenticator has the expected configuration
662-
$this->assertEquals('user_name', $form->getConfig('fields.username'));
663-
$this->assertEquals('pass_word', $form->getConfig('fields.password'));
671+
// Verify the fields are properly configured on the identifier
672+
$passwordIdentifier = $identifier->get('Password');
673+
$this->assertEquals('user_name', $passwordIdentifier->getConfig('fields.username'));
674+
$this->assertEquals('pass_word', $passwordIdentifier->getConfig('fields.password'));
664675
}
665676
}

0 commit comments

Comments
 (0)