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

daycry / auth / 25518434194

07 May 2026 07:49PM UTC coverage: 58.608% (-6.4%) from 64.989%
25518434194

push

github

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

Implement security enhancements and new account features

277 of 1030 new or added lines in 55 files covered. (26.89%)

11 existing lines in 6 files now uncovered.

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 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)
19✔
59
    {
60
    }
19✔
61

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

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

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

89
        return $this;
17✔
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
11✔
187
    {
188
        $sessionState = session()->get('oauth2state');
11✔
189

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

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

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

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

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

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

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

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

221
            return $user;
10✔
222
        } catch (IdentityProviderException $e) {
×
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}
236
     */
237
    protected function extractUserData(ResourceOwnerInterface $resourceOwner): array
10✔
238
    {
239
        $id    = (string) $resourceOwner->getId();
10✔
240
        $email = null;
10✔
241
        $name  = null;
10✔
242

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

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

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

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

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

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

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

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

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

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

302
        $extraJson = json_encode($extraData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
10✔
303

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

306
        $user = null;
10✔
307

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

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

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

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

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

334
                $provider->addToDefaultGroup($user);
8✔
335
            }
336

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

346
        auth()->login($user);
10✔
347

348
        return $user;
10✔
349
    }
350

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

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

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

371
            return [];
×
372
        }
373
    }
374

375
    /**
376
     * Get the stored profile data for a user's OAuth identity.
377
     *
378
     * @return array<string, mixed>
379
     */
380
    public function getProfileData(User $user): array
2✔
381
    {
382
        return $this->getRepository()->getProfileData((int) $user->id, $this->providerName);
2✔
383
    }
384
}
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