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

daycry / auth / 23341393117

20 Mar 2026 11:45AM UTC coverage: 64.989% (+1.2%) from 63.745%
23341393117

push

github

daycry
Merge branch 'development' of https://github.com/daycry/auth into development

4 of 4 new or added lines in 2 files covered. (100.0%)

315 existing lines in 13 files now uncovered.

3306 of 5087 relevant lines covered (64.99%)

47.03 hits per line

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

85.03
/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✔
UNCOV
108
            $tenant                           = $providerConfig['tenant'];
×
UNCOV
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✔
UNCOV
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✔
UNCOV
155
                $extraData['scopes_granted'] = is_string($tokenValues['scope'])
×
UNCOV
156
                    ? explode(' ', $tokenValues['scope'])
×
UNCOV
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✔
UNCOV
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
        if ($state === '' || $state === '0' || ($state !== $sessionState)) {
11✔
193
            session()->remove('oauth2state');
1✔
194

195
            throw new AuthenticationException(lang('Auth.invalidOauthState'));
1✔
196
        }
197

198
        session()->remove('oauth2state');
10✔
199

200
        try {
201
            $token       = $this->provider->getAccessToken('authorization_code', ['code' => $code]);
10✔
202
            $userProfile = $this->provider->getResourceOwner($token);
10✔
203
            $profileData = $this->fetchProfileFields($token, $userProfile);
10✔
204

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

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

209
            if ($profileData !== []) {
10✔
210
                Events::trigger('oauth-profile-fetched', $user, $this->providerName, $profileData);
3✔
211
            }
212

213
            return $user;
10✔
UNCOV
214
        } catch (IdentityProviderException $e) {
×
UNCOV
215
            throw new AuthenticationException($e->getMessage());
×
216
        }
217
    }
218

219
    /**
220
     * Extract normalised email, name, and social ID from a resource owner.
221
     *
222
     * Each League provider exposes data differently:
223
     *   - Google / Facebook / GitHub : getEmail(), getName()
224
     *   - Azure                      : claim('email') / claim('unique_name')
225
     *   - GenericProvider & others   : toArray() fallback
226
     *
227
     * @return array{id: string, email: string|null, name: string|null}
228
     */
229
    protected function extractUserData(ResourceOwnerInterface $resourceOwner): array
10✔
230
    {
231
        $id    = (string) $resourceOwner->getId();
10✔
232
        $email = null;
10✔
233
        $name  = null;
10✔
234

235
        if ($this->providerName === 'azure') {
10✔
236
            // Azure resource-owner uses claim() for user attributes
237
            /** @var AzureResourceOwner $resourceOwner */
UNCOV
238
            $email = $resourceOwner->claim('email') ?: $resourceOwner->claim('unique_name');
×
UNCOV
239
            $name  = $resourceOwner->claim('name');
×
240
        } else {
241
            // Standard League providers implement getEmail() / getName()
242
            if (method_exists($resourceOwner, 'getEmail')) {
10✔
UNCOV
243
                $email = $resourceOwner->getEmail();
×
244
            }
245

246
            if (method_exists($resourceOwner, 'getName')) {
10✔
UNCOV
247
                $name = $resourceOwner->getName();
×
248
            }
249

250
            // toArray() fallback for GenericProvider or providers without typed methods
251
            if ($email === null || $name === null) {
10✔
252
                $data = $resourceOwner->toArray();
10✔
253
                $email ??= $data['email'] ?? null;
10✔
254
                $name ??= $data['name'] ?? $data['login'] ?? null;
10✔
255
            }
256
        }
257

258
        return ['id' => $id, 'email' => $email, 'name' => $name];
10✔
259
    }
260

261
    /**
262
     * Find or create a local user and OAuth identity for the resource owner.
263
     *
264
     * @param array<string, mixed> $profileData Extra profile fields from the resolver
265
     */
266
    protected function processUser(ResourceOwnerInterface $userProfile, AccessTokenInterface $token, array $profileData = []): User
10✔
267
    {
268
        $repo = $this->getRepository();
10✔
269

270
        $data     = $this->extractUserData($userProfile);
10✔
271
        $socialId = $data['id'];
10✔
272
        $email    = $data['email'];
10✔
273
        $name     = $data['name'];
10✔
274

275
        if (empty($email)) {
10✔
UNCOV
276
            throw new AuthenticationException(lang('Auth.emailNotFoundInOauth'));
×
277
        }
278

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

281
        // Scopes (RFC 6749 §3.3: space-delimited)
282
        $tokenValues = $token->getValues();
10✔
283
        if (isset($tokenValues['scope'])) {
10✔
284
            $extraData['scopes_granted'] = is_string($tokenValues['scope'])
1✔
285
                ? explode(' ', $tokenValues['scope'])
1✔
UNCOV
286
                : (array) $tokenValues['scope'];
×
287
        }
288

289
        if ($profileData !== []) {
10✔
290
            $extraData['profile']            = $profileData;
3✔
291
            $extraData['profile_fetched_at'] = Time::now()->toDateTimeString();
3✔
292
        }
293

294
        $extraJson = json_encode($extraData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
10✔
295

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

298
        $user = null;
10✔
299

300
        if ($identity) {
10✔
301
            /** @var UserIdentity $identity */
302
            $user = $identity->user();
1✔
303

304
            // Update token and profile data on re-login
305
            $identity->secret2 = $token->getToken();
1✔
306
            $identity->extra   = $extraJson;
1✔
307

308
            if ($token->getExpires()) {
1✔
UNCOV
309
                $identity->expires = Time::createFromTimestamp($token->getExpires());
×
310
            }
311

312
            $repo->updateOAuthIdentity($identity);
1✔
313
        } else {
314
            $provider = auth()->getProvider();
9✔
315
            $user     = $provider->findByCredentials(['email' => $email]);
9✔
316

317
            if (! $user instanceof User) {
9✔
318
                $username = explode('@', $email)[0] . '_' . bin2hex(random_bytes(3));
8✔
319
                $user     = new User([
8✔
320
                    'username' => $username,
8✔
321
                    'active'   => true,
8✔
322
                ]);
8✔
323
                $provider->save($user);
8✔
324
                $user = $provider->findById($provider->getInsertID());
8✔
325

326
                $provider->addToDefaultGroup($user);
8✔
327
            }
328

329
            $repo->createOAuthIdentity((int) $user->id, $this->providerName, [
9✔
330
                'name'    => $name ?? $email,
9✔
331
                'secret'  => $socialId,
9✔
332
                'secret2' => $token->getToken(),
9✔
333
                'extra'   => $extraJson,
9✔
334
                'expires' => $token->getExpires() ? Time::createFromTimestamp($token->getExpires()) : null,
9✔
335
            ]);
9✔
336
        }
337

338
        auth()->login($user);
10✔
339

340
        return $user;
10✔
341
    }
342

343
    /**
344
     * Fetch additional profile fields using the appropriate resolver.
345
     *
346
     * @return array<string, mixed>
347
     */
348
    private function fetchProfileFields(AccessTokenInterface $token, ResourceOwnerInterface $resourceOwner): array
10✔
349
    {
350
        $providerConfig = $this->config->providers[$this->providerName] ?? [];
10✔
351
        $fields         = $providerConfig['fields'] ?? [];
10✔
352

353
        if ($fields === []) {
10✔
354
            return [];
7✔
355
        }
356

357
        try {
358
            return ProfileResolverFactory::create($this->providerName, $providerConfig)
3✔
359
                ->fetchFields($this->provider, $token, $resourceOwner, $fields, $providerConfig);
3✔
UNCOV
360
        } catch (Throwable $e) {
×
UNCOV
361
            log_message('warning', 'OAuth profile fetch failed: ' . $e->getMessage());
×
362

UNCOV
363
            return [];
×
364
        }
365
    }
366

367
    /**
368
     * Get the stored profile data for a user's OAuth identity.
369
     *
370
     * @return array<string, mixed>
371
     */
372
    public function getProfileData(User $user): array
2✔
373
    {
374
        return $this->getRepository()->getProfileData((int) $user->id, $this->providerName);
2✔
375
    }
376
}
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