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

daycry / auth / 26937880755

04 Jun 2026 07:38AM UTC coverage: 75.983% (+4.4%) from 71.569%
26937880755

push

github

web-flow
Merge pull request #56 from daycry/development

feat

613 of 719 new or added lines in 42 files covered. (85.26%)

3 existing lines in 3 files now uncovered.

5179 of 6816 relevant lines covered (75.98%)

69.66 hits per line

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

84.82
/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 AbstractProvider $provider;
42
    protected string $providerName;
43
    private ?OAuthTokenRepository $repository = null;
44

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

58
    public function __construct(protected AuthConfig $config)
23✔
59
    {
60
    }
23✔
61

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

72
    /**
73
     * Centralised model() call — avoids repeating it across methods.
74
     */
75
    private function getIdentityModel(): UserIdentityModel
20✔
76
    {
77
        /** @var UserIdentityModel */
78
        return model(UserIdentityModel::class);
20✔
79
    }
80

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

89
        return $this;
21✔
90
    }
91

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

101
        $this->providerName = $providerName;
1✔
102
        $providerConfig     = $this->config->providers[$providerName];
1✔
103

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

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

114
        return $this;
1✔
115
    }
116

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

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

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

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

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

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

144
            $identity->secret2 = $token->getToken();
2✔
145

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

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

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

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

164
            $repo->updateOAuthIdentity($identity);
2✔
165

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

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

180
        return redirect()->to($authorizationUrl);
2✔
181
    }
182

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

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

199
            throw new AuthenticationException(lang('Auth.invalidOauthState'));
1✔
200
        }
201

202
        session()->remove('oauth2state');
14✔
203

204
        if ($code === '') {
14✔
205
            throw new AuthenticationException(lang('Auth.invalidOauthState'));
×
206
        }
207

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

213
            $user = $this->processUser($userProfile, $token, $profileData);
14✔
214

215
            Events::trigger('oauth-login', $user, $this->providerName);
12✔
216

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

221
            return $user;
12✔
222
        } catch (IdentityProviderException $e) {
2✔
223
            throw new AuthenticationException($e->getMessage());
×
224
        }
225
    }
226

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

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

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

260
            // toArray() exposes the raw claims for GenericProvider/OIDC providers
261
            // and carries the verified-email signal we need below.
262
            $data = $resourceOwner->toArray();
14✔
263
            $email ??= $data['email'] ?? null;
14✔
264
            $name ??= $data['name'] ?? $data['login'] ?? null;
14✔
265
            // OIDC standard `email_verified` (Google, generic OIDC) and the
266
            // legacy Google `verified_email` field.
267
            $emailVerified = self::isClaimTrue($data['email_verified'] ?? $data['verified_email'] ?? null);
14✔
268
        }
269

270
        return ['id' => $id, 'email' => $email, 'name' => $name, 'email_verified' => $emailVerified];
14✔
271
    }
272

273
    /**
274
     * Normalises a verified-email claim that providers express inconsistently
275
     * (bool true, int 1, or the strings "1"/"true") into a strict boolean.
276
     */
277
    private static function isClaimTrue(mixed $value): bool
14✔
278
    {
279
        return $value === true
14✔
280
            || $value === 1
14✔
281
            || $value === '1'
14✔
282
            || (is_string($value) && strtolower($value) === 'true');
14✔
283
    }
284

285
    /**
286
     * Find or create a local user and OAuth identity for the resource owner.
287
     *
288
     * @param array<string, mixed> $profileData Extra profile fields from the resolver
289
     */
290
    protected function processUser(ResourceOwnerInterface $userProfile, AccessTokenInterface $token, array $profileData = []): User
14✔
291
    {
292
        $repo = $this->getRepository();
14✔
293

294
        $data          = $this->extractUserData($userProfile);
14✔
295
        $socialId      = $data['id'];
14✔
296
        $email         = $data['email'];
14✔
297
        $name          = $data['name'];
14✔
298
        $emailVerified = $data['email_verified'];
14✔
299

300
        if (empty($email)) {
14✔
301
            throw new AuthenticationException(lang('Auth.emailNotFoundInOauth'));
×
302
        }
303

304
        $extraData = ['refresh_token' => $token->getRefreshToken()];
14✔
305

306
        // Scopes (RFC 6749 §3.3: space-delimited)
307
        $tokenValues = $token->getValues();
14✔
308
        if (isset($tokenValues['scope'])) {
14✔
309
            $extraData['scopes_granted'] = is_string($tokenValues['scope'])
1✔
310
                ? explode(' ', $tokenValues['scope'])
1✔
311
                : (array) $tokenValues['scope'];
×
312
        }
313

314
        if ($profileData !== []) {
14✔
315
            $extraData['profile']            = $profileData;
3✔
316
            $extraData['profile_fetched_at'] = Time::now()->toDateTimeString();
3✔
317
        }
318

319
        $extraJson = json_encode($extraData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
14✔
320

321
        // Explicit linking mode: an authenticated user is attaching this provider
322
        // to their own account (initiated via OauthController::link). This skips
323
        // the e-mail-merge path entirely and does not require a verified e-mail,
324
        // because the user is already authenticated and acting deliberately.
325
        $linkUserId = (int) (session('oauth_link_user_id') ?? 0);
14✔
326
        session()->remove('oauth_link_user_id');
14✔
327

328
        if ($linkUserId > 0) {
14✔
329
            return $this->linkProviderToUser($linkUserId, $socialId, $name, $email, $extraJson, $token);
2✔
330
        }
331

332
        $identity = $repo->findByProviderAndSocialId($this->providerName, $socialId);
12✔
333

334
        $user = null;
12✔
335

336
        if ($identity instanceof UserIdentity) {
12✔
337
            /** @var UserIdentity $identity */
338
            $user = $identity->user();
1✔
339

340
            // Update token and profile data on re-login
341
            $identity->secret2 = $token->getToken();
1✔
342
            $identity->extra   = $extraJson;
1✔
343

344
            if ($token->getExpires()) {
1✔
345
                $identity->expires = Time::createFromTimestamp($token->getExpires());
×
346
            }
347

348
            $repo->updateOAuthIdentity($identity);
1✔
349
        } else {
350
            $provider = auth()->getProvider();
11✔
351
            $user     = $provider->findByCredentials(['email' => $email]);
11✔
352

353
            if ($user instanceof User) {
11✔
354
                // Linking a social identity to an EXISTING local account. Require
355
                // the provider to assert the e-mail is verified — otherwise an
356
                // attacker who registers a social account with the victim's e-mail
357
                // would be silently logged in as the victim (unverified-email
358
                // account takeover). Operators may opt in per provider.
359
                $allowUnverified = (bool) ($this->config->providers[$this->providerName]['allowUnverifiedEmailLink'] ?? false);
3✔
360

361
                if (! $emailVerified && ! $allowUnverified) {
3✔
362
                    throw new AuthenticationException(lang('Auth.oauthEmailUnverified'));
1✔
363
                }
364
            } else {
365
                $username = explode('@', $email)[0] . '_' . bin2hex(random_bytes(3));
8✔
366
                $user     = new User([
8✔
367
                    'username' => $username,
8✔
368
                    'active'   => true,
8✔
369
                ]);
8✔
370
                $provider->save($user);
8✔
371
                $user = $provider->findById($provider->getInsertID());
8✔
372

373
                $provider->addToDefaultGroup($user);
8✔
374
            }
375

376
            $repo->createOAuthIdentity((int) $user->id, $this->providerName, [
10✔
377
                'name'    => $name ?? $email,
10✔
378
                'secret'  => $socialId,
10✔
379
                'secret2' => $token->getToken(),
10✔
380
                'extra'   => $extraJson,
10✔
381
                'expires' => $token->getExpires() ? Time::createFromTimestamp($token->getExpires()) : null,
10✔
382
            ]);
10✔
383
        }
384

385
        auth()->login($user);
11✔
386

387
        return $user;
11✔
388
    }
389

390
    /**
391
     * Links the current provider's social identity to an already-authenticated
392
     * user (explicit linking). Refuses when the social account is already bound
393
     * to a different local user.
394
     */
395
    private function linkProviderToUser(int $userId, string $socialId, ?string $name, string $email, string $extraJson, AccessTokenInterface $token): User
2✔
396
    {
397
        $repo     = $this->getRepository();
2✔
398
        $provider = auth()->getProvider();
2✔
399

400
        $user = $provider->findById($userId);
2✔
401

402
        if (! $user instanceof User) {
2✔
NEW
403
            throw new AuthenticationException(lang('Auth.invalidUser'));
×
404
        }
405

406
        $existing = $repo->findByProviderAndSocialId($this->providerName, $socialId);
2✔
407

408
        if ($existing instanceof UserIdentity) {
2✔
409
            if ((int) $existing->user_id !== $userId) {
1✔
410
                // Social account already linked to a different local user.
411
                throw new AuthenticationException(lang('Auth.oauthAlreadyLinked'));
1✔
412
            }
413

414
            // Already linked to this user — refresh the stored token/profile.
NEW
415
            $existing->secret2 = $token->getToken();
×
NEW
416
            $existing->extra   = $extraJson;
×
417

NEW
418
            if ($token->getExpires()) {
×
NEW
419
                $existing->expires = Time::createFromTimestamp($token->getExpires());
×
420
            }
421

NEW
422
            $repo->updateOAuthIdentity($existing);
×
423
        } else {
424
            $repo->createOAuthIdentity($userId, $this->providerName, [
1✔
425
                'name'    => $name ?? $email,
1✔
426
                'secret'  => $socialId,
1✔
427
                'secret2' => $token->getToken(),
1✔
428
                'extra'   => $extraJson,
1✔
429
                'expires' => $token->getExpires() ? Time::createFromTimestamp($token->getExpires()) : null,
1✔
430
            ]);
1✔
431
        }
432

433
        auth()->login($user);
1✔
434

435
        return $user;
1✔
436
    }
437

438
    /**
439
     * Fetch additional profile fields using the appropriate resolver.
440
     *
441
     * @return array<string, mixed>
442
     */
443
    private function fetchProfileFields(AccessTokenInterface $token, ResourceOwnerInterface $resourceOwner): array
14✔
444
    {
445
        $providerConfig = $this->config->providers[$this->providerName] ?? [];
14✔
446
        $fields         = $providerConfig['fields'] ?? [];
14✔
447

448
        if ($fields === []) {
14✔
449
            return [];
11✔
450
        }
451

452
        try {
453
            return ProfileResolverFactory::create($this->providerName, $providerConfig)
3✔
454
                ->fetchFields($this->provider, $token, $resourceOwner, $fields, $providerConfig);
3✔
455
        } catch (Throwable $e) {
×
456
            log_message('warning', 'OAuth profile fetch failed: ' . $e->getMessage());
×
457

458
            return [];
×
459
        }
460
    }
461

462
    /**
463
     * Get the stored profile data for a user's OAuth identity.
464
     *
465
     * @return array<string, mixed>
466
     */
467
    public function getProfileData(User $user): array
2✔
468
    {
469
        return $this->getRepository()->getProfileData((int) $user->id, $this->providerName);
2✔
470
    }
471
}
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