• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / auth / 25507696361

07 May 2026 03:28PM UTC coverage: 58.608% (-6.4%) from 64.989%
25507696361

push

github

daycry
Regenerate PHPStan baseline for the new sources

Captures the 22 new files added in 72c122c and re-balances the existing
suppressions so the new model() / config() class-const fetches against
codeigniter4-standard rule sets are pinned to the new lines without
masking real issues elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

3544 of 6047 relevant lines covered (58.61%)

47.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.87
/src/Libraries/Oauth/OauthManager.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Auth\Libraries\Oauth;
15

16
use CodeIgniter\Events\Events;
17
use CodeIgniter\HTTP\RedirectResponse;
18
use CodeIgniter\I18n\Time;
19
use Daycry\Auth\Config\AuthOAuth as AuthConfig;
20
use Daycry\Auth\Entities\User;
21
use Daycry\Auth\Entities\UserIdentity;
22
use Daycry\Auth\Exceptions\AuthenticationException;
23
use Daycry\Auth\Libraries\Oauth\ProfileResolver\ProfileResolverFactory;
24
use Daycry\Auth\Models\OAuthTokenRepository;
25
use Daycry\Auth\Models\UserIdentityModel;
26
use League\OAuth2\Client\Grant\RefreshToken;
27
use League\OAuth2\Client\Provider\AbstractProvider;
28
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
29
use League\OAuth2\Client\Provider\Facebook;
30
use League\OAuth2\Client\Provider\GenericProvider;
31
use League\OAuth2\Client\Provider\Github;
32
use League\OAuth2\Client\Provider\Google;
33
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
34
use League\OAuth2\Client\Token\AccessTokenInterface;
35
use TheNetworg\OAuth2\Client\Provider\Azure;
36
use TheNetworg\OAuth2\Client\Provider\AzureResourceOwner;
37
use Throwable;
38

39
class OauthManager
40
{
41
    protected AuthConfig $config;
42
    protected AbstractProvider $provider;
43
    protected string $providerName;
44
    private ?OAuthTokenRepository $repository = null;
45

46
    /**
47
     * Map of provider alias โ†’ FQCN for well-known providers.
48
     * Any provider not listed here falls back to GenericProvider.
49
     *
50
     * @var array<string, class-string<AbstractProvider>>
51
     */
52
    protected array $providerMap = [
53
        'azure'    => Azure::class,
54
        'google'   => Google::class,
55
        'facebook' => Facebook::class,
56
        'github'   => Github::class,
57
    ];
58

59
    public function __construct(AuthConfig $config)
19✔
60
    {
61
        $this->config = $config;
19✔
62
    }
63

64
    /**
65
     * Lazy-initialise the OAuth token repository.
66
     */
67
    private function getRepository(): OAuthTokenRepository
16✔
68
    {
69
        return $this->repository ??= new OAuthTokenRepository(
16✔
70
            $this->getIdentityModel(),
16✔
71
        );
16✔
72
    }
73

74
    /**
75
     * Centralised model() call โ€” avoids repeating it across methods.
76
     */
77
    private function getIdentityModel(): UserIdentityModel
16✔
78
    {
79
        /** @var UserIdentityModel */
80
        return model(UserIdentityModel::class);
16✔
81
    }
82

83
    /**
84
     * Set the provider instance directly (testing).
85
     */
86
    public function setProviderInstance(AbstractProvider $provider, string $name = 'test'): self
17✔
87
    {
88
        $this->provider     = $provider;
17✔
89
        $this->providerName = $name;
17✔
90

91
        return $this;
17✔
92
    }
93

94
    /**
95
     * Resolve and instantiate the OAuth provider for the given alias.
96
     */
97
    public function setProvider(string $providerName): self
1✔
98
    {
99
        if (! isset($this->config->providers[$providerName])) {
1✔
100
            throw new AuthenticationException(lang('Auth.unknownOauthProvider', [$providerName]));
×
101
        }
102

103
        $this->providerName = $providerName;
1✔
104
        $providerConfig     = $this->config->providers[$providerName];
1✔
105

106
        // Azure needs tenant-specific URLs when a tenant is given
107
        if ($providerName === 'azure' && ! empty($providerConfig['tenant'])) {
1✔
108
            $tenant                           = $providerConfig['tenant'];
×
109
            $providerConfig['urlAuthorize']   = 'https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/authorize';
×
110
            $providerConfig['urlAccessToken'] = 'https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/token';
×
111
        }
112

113
        $class          = $this->providerMap[$providerName] ?? GenericProvider::class;
1✔
114
        $this->provider = new $class($providerConfig);
1✔
115

116
        return $this;
1✔
117
    }
118

119
    /**
120
     * Refresh the stored access token for a user via the refresh-token grant.
121
     */
122
    public function refreshAccessToken(User $user): ?AccessTokenInterface
4✔
123
    {
124
        if (! isset($this->provider)) {
4✔
125
            throw new AuthenticationException(lang('Auth.unknownOauthProvider', [$this->providerName ?? 'null']));
×
126
        }
127

128
        $repo     = $this->getRepository();
4✔
129
        $identity = $repo->findByUserAndProvider((int) $user->id, $this->providerName);
4✔
130

131
        if ($identity === null || empty($identity->extra)) {
4✔
132
            return null;
1✔
133
        }
134

135
        $extraData    = $repo->parseExtra($identity->extra);
3✔
136
        $refreshToken = $extraData['refresh_token'] ?? null;
3✔
137

138
        if (empty($refreshToken)) {
3✔
139
            return null;
×
140
        }
141

142
        try {
143
            $grant = new RefreshToken();
3✔
144
            $token = $this->provider->getAccessToken($grant, ['refresh_token' => $refreshToken]);
3✔
145

146
            $identity->secret2 = $token->getToken();
2✔
147

148
            if ($token->getRefreshToken()) {
2✔
149
                $extraData['refresh_token'] = $token->getRefreshToken();
2✔
150
            }
151

152
            // Update scopes if the refreshed token includes them
153
            $tokenValues = $token->getValues();
2✔
154
            if (isset($tokenValues['scope'])) {
2✔
155
                $extraData['scopes_granted'] = is_string($tokenValues['scope'])
×
156
                    ? explode(' ', $tokenValues['scope'])
×
157
                    : (array) $tokenValues['scope'];
×
158
            }
159

160
            $identity->extra = json_encode($extraData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
2✔
161

162
            if ($token->getExpires()) {
2✔
163
                $identity->expires = Time::createFromTimestamp($token->getExpires());
×
164
            }
165

166
            $repo->updateOAuthIdentity($identity);
2✔
167

168
            return $token;
2✔
169
        } catch (IdentityProviderException) {
1✔
170
            return null;
1✔
171
        }
172
    }
173

174
    /**
175
     * Redirect the user to the OAuth provider's authorization page.
176
     */
177
    public function redirect(): RedirectResponse
2✔
178
    {
179
        $authorizationUrl = $this->provider->getAuthorizationUrl();
2✔
180
        session()->set('oauth2state', $this->provider->getState());
2✔
181

182
        return redirect()->to($authorizationUrl);
2✔
183
    }
184

185
    /**
186
     * Handle the authorization callback from the OAuth provider.
187
     */
188
    public function handleCallback(string $code, string $state): User
11✔
189
    {
190
        $sessionState = session()->get('oauth2state');
11✔
191

192
        // CSRF check โ€” reject empty state and use timing-safe comparison.
193
        if (
194
            $state === ''
11✔
195
            || ! is_string($sessionState)
11✔
196
            || $sessionState === ''
11✔
197
            || ! hash_equals($sessionState, $state)
11✔
198
        ) {
199
            session()->remove('oauth2state');
1✔
200

201
            throw new AuthenticationException(lang('Auth.invalidOauthState'));
1✔
202
        }
203

204
        session()->remove('oauth2state');
10✔
205

206
        if ($code === '') {
10✔
207
            throw new AuthenticationException(lang('Auth.invalidOauthState'));
×
208
        }
209

210
        try {
211
            $token       = $this->provider->getAccessToken('authorization_code', ['code' => $code]);
10✔
212
            $userProfile = $this->provider->getResourceOwner($token);
10✔
213
            $profileData = $this->fetchProfileFields($token, $userProfile);
10✔
214

215
            $user = $this->processUser($userProfile, $token, $profileData);
10✔
216

217
            Events::trigger('oauth-login', $user, $this->providerName);
10✔
218

219
            if ($profileData !== []) {
10✔
220
                Events::trigger('oauth-profile-fetched', $user, $this->providerName, $profileData);
3✔
221
            }
222

223
            return $user;
10✔
224
        } catch (IdentityProviderException $e) {
×
225
            throw new AuthenticationException($e->getMessage());
×
226
        }
227
    }
228

229
    /**
230
     * Extract normalised email, name, and social ID from a resource owner.
231
     *
232
     * Each League provider exposes data differently:
233
     *   - Google / Facebook / GitHub : getEmail(), getName()
234
     *   - Azure                      : claim('email') / claim('unique_name')
235
     *   - GenericProvider & others   : toArray() fallback
236
     *
237
     * @return array{id: string, email: string|null, name: string|null}
238
     */
239
    protected function extractUserData(ResourceOwnerInterface $resourceOwner): array
10✔
240
    {
241
        $id    = (string) $resourceOwner->getId();
10✔
242
        $email = null;
10✔
243
        $name  = null;
10✔
244

245
        if ($this->providerName === 'azure') {
10✔
246
            // Azure resource-owner uses claim() for user attributes
247
            /** @var AzureResourceOwner $resourceOwner */
248
            $email = $resourceOwner->claim('email') ?: $resourceOwner->claim('unique_name');
×
249
            $name  = $resourceOwner->claim('name');
×
250
        } else {
251
            // Standard League providers implement getEmail() / getName()
252
            if (method_exists($resourceOwner, 'getEmail')) {
10✔
253
                $email = $resourceOwner->getEmail();
×
254
            }
255

256
            if (method_exists($resourceOwner, 'getName')) {
10✔
257
                $name = $resourceOwner->getName();
×
258
            }
259

260
            // toArray() fallback for GenericProvider or providers without typed methods
261
            if ($email === null || $name === null) {
10✔
262
                $data = $resourceOwner->toArray();
10✔
263
                $email ??= $data['email'] ?? null;
10✔
264
                $name ??= $data['name'] ?? $data['login'] ?? null;
10✔
265
            }
266
        }
267

268
        return ['id' => $id, 'email' => $email, 'name' => $name];
10✔
269
    }
270

271
    /**
272
     * Find or create a local user and OAuth identity for the resource owner.
273
     *
274
     * @param array<string, mixed> $profileData Extra profile fields from the resolver
275
     */
276
    protected function processUser(ResourceOwnerInterface $userProfile, AccessTokenInterface $token, array $profileData = []): User
10✔
277
    {
278
        $repo = $this->getRepository();
10✔
279

280
        $data     = $this->extractUserData($userProfile);
10✔
281
        $socialId = $data['id'];
10✔
282
        $email    = $data['email'];
10✔
283
        $name     = $data['name'];
10✔
284

285
        if (empty($email)) {
10✔
286
            throw new AuthenticationException(lang('Auth.emailNotFoundInOauth'));
×
287
        }
288

289
        $extraData = ['refresh_token' => $token->getRefreshToken()];
10✔
290

291
        // Scopes (RFC 6749 ยง3.3: space-delimited)
292
        $tokenValues = $token->getValues();
10✔
293
        if (isset($tokenValues['scope'])) {
10✔
294
            $extraData['scopes_granted'] = is_string($tokenValues['scope'])
1✔
295
                ? explode(' ', $tokenValues['scope'])
1✔
296
                : (array) $tokenValues['scope'];
×
297
        }
298

299
        if ($profileData !== []) {
10✔
300
            $extraData['profile']            = $profileData;
3✔
301
            $extraData['profile_fetched_at'] = Time::now()->toDateTimeString();
3✔
302
        }
303

304
        $extraJson = json_encode($extraData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
10✔
305

306
        $identity = $repo->findByProviderAndSocialId($this->providerName, $socialId);
10✔
307

308
        $user = null;
10✔
309

310
        if ($identity) {
10✔
311
            /** @var UserIdentity $identity */
312
            $user = $identity->user();
1✔
313

314
            // Update token and profile data on re-login
315
            $identity->secret2 = $token->getToken();
1✔
316
            $identity->extra   = $extraJson;
1✔
317

318
            if ($token->getExpires()) {
1✔
319
                $identity->expires = Time::createFromTimestamp($token->getExpires());
×
320
            }
321

322
            $repo->updateOAuthIdentity($identity);
1✔
323
        } else {
324
            $provider = auth()->getProvider();
9✔
325
            $user     = $provider->findByCredentials(['email' => $email]);
9✔
326

327
            if (! $user instanceof User) {
9✔
328
                $username = explode('@', $email)[0] . '_' . bin2hex(random_bytes(3));
8✔
329
                $user     = new User([
8✔
330
                    'username' => $username,
8✔
331
                    'active'   => true,
8✔
332
                ]);
8✔
333
                $provider->save($user);
8✔
334
                $user = $provider->findById($provider->getInsertID());
8✔
335

336
                $provider->addToDefaultGroup($user);
8✔
337
            }
338

339
            $repo->createOAuthIdentity((int) $user->id, $this->providerName, [
9✔
340
                'name'    => $name ?? $email,
9✔
341
                'secret'  => $socialId,
9✔
342
                'secret2' => $token->getToken(),
9✔
343
                'extra'   => $extraJson,
9✔
344
                'expires' => $token->getExpires() ? Time::createFromTimestamp($token->getExpires()) : null,
9✔
345
            ]);
9✔
346
        }
347

348
        auth()->login($user);
10✔
349

350
        return $user;
10✔
351
    }
352

353
    /**
354
     * Fetch additional profile fields using the appropriate resolver.
355
     *
356
     * @return array<string, mixed>
357
     */
358
    private function fetchProfileFields(AccessTokenInterface $token, ResourceOwnerInterface $resourceOwner): array
10✔
359
    {
360
        $providerConfig = $this->config->providers[$this->providerName] ?? [];
10✔
361
        $fields         = $providerConfig['fields'] ?? [];
10✔
362

363
        if ($fields === []) {
10✔
364
            return [];
7✔
365
        }
366

367
        try {
368
            return ProfileResolverFactory::create($this->providerName, $providerConfig)
3✔
369
                ->fetchFields($this->provider, $token, $resourceOwner, $fields, $providerConfig);
3✔
370
        } catch (Throwable $e) {
×
371
            log_message('warning', 'OAuth profile fetch failed: ' . $e->getMessage());
×
372

373
            return [];
×
374
        }
375
    }
376

377
    /**
378
     * Get the stored profile data for a user's OAuth identity.
379
     *
380
     * @return array<string, mixed>
381
     */
382
    public function getProfileData(User $user): array
2✔
383
    {
384
        return $this->getRepository()->getProfileData((int) $user->id, $this->providerName);
2✔
385
    }
386
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc