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

daycry / auth / 22520725744

27 Feb 2026 09:30PM UTC coverage: 65.761% (-1.1%) from 66.864%
22520725744

push

github

daycry
Add StatelessAuthenticator and refactor token handling

Introduce StatelessAuthenticator as a shared base for JWT/AccessToken and centralize token extraction (getTokenFromRequest). Refactor JWT and AccessToken to extend it and simplify header/query parsing. Add Utils::generateNumericCode and use it in Email2FA/EmailActivator to replace duplicated generators. Centralize model() calls in traits (HasAccessTokens, HasDeviceSessions, HasTotp) via small private getters. Improve filters and error handling: add buildDeniedResponse in AbstractAuthFilter, adjust Group/Permission filters to return ResponseInterface and reuse the builder. Replace static authorization flags with instance properties in AuthenticationException/AuthorizationException and update ExceptionHandler to read them safely. Misc: small controller/type fixes, email helper guard, DeviceSessionModel null handling, active-group/permission query fixes, phpstan baseline updates, and adjust tests to expect 403 for denied JSON responses.

56 of 68 new or added lines in 19 files covered. (82.35%)

115 existing lines in 7 files now uncovered.

2614 of 3975 relevant lines covered (65.76%)

42.9 hits per line

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

67.68
/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\HTTP\RedirectResponse;
17
use CodeIgniter\I18n\Time;
18
use Daycry\Auth\Config\Auth as AuthConfig;
19
use Daycry\Auth\Entities\User;
20
use Daycry\Auth\Entities\UserIdentity;
21
use Daycry\Auth\Exceptions\AuthenticationException;
22
use Daycry\Auth\Models\UserIdentityModel;
23
use League\OAuth2\Client\Grant\RefreshToken;
24
use League\OAuth2\Client\Provider\AbstractProvider;
25
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
26
use League\OAuth2\Client\Provider\Facebook;
27
use League\OAuth2\Client\Provider\GenericProvider;
28
use League\OAuth2\Client\Provider\Github;
29
use League\OAuth2\Client\Provider\Google;
30
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
31
use League\OAuth2\Client\Token\AccessTokenInterface;
32
use TheNetworg\OAuth2\Client\Provider\Azure;
33
use TheNetworg\OAuth2\Client\Provider\AzureResourceOwner;
34

35
class OauthManager
36
{
37
    protected AuthConfig $config;
38
    protected AbstractProvider $provider;
39
    protected string $providerName;
40

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

54
    public function __construct(AuthConfig $config)
55
    {
56
        $this->config = $config;
5✔
57
    }
58

59
    /**
60
     * Set the provider instance directly (testing).
61
     */
62
    public function setProviderInstance(AbstractProvider $provider, string $name = 'test'): self
63
    {
64
        $this->provider     = $provider;
3✔
65
        $this->providerName = $name;
3✔
66

67
        return $this;
3✔
68
    }
69

70
    /**
71
     * Resolve and instantiate the OAuth provider for the given alias.
72
     */
73
    public function setProvider(string $providerName): self
74
    {
75
        if (! isset($this->config->providers[$providerName])) {
1✔
UNCOV
76
            throw new AuthenticationException(lang('Auth.unknownOauthProvider', [$providerName]));
×
77
        }
78

79
        $this->providerName = $providerName;
1✔
80
        $providerConfig     = $this->config->providers[$providerName];
1✔
81

82
        // Azure needs tenant-specific URLs when a tenant is given
83
        if ($providerName === 'azure' && ! empty($providerConfig['tenant'])) {
1✔
84
            $tenant                           = $providerConfig['tenant'];
×
UNCOV
85
            $providerConfig['urlAuthorize']   = 'https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/authorize';
×
UNCOV
86
            $providerConfig['urlAccessToken'] = 'https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/token';
×
87
        }
88

89
        $class          = $this->providerMap[$providerName] ?? GenericProvider::class;
1✔
90
        $this->provider = new $class($providerConfig);
1✔
91

92
        return $this;
1✔
93
    }
94

95
    /**
96
     * Refresh the stored access token for a user via the refresh-token grant.
97
     */
98
    public function refreshAccessToken(User $user): ?AccessTokenInterface
99
    {
UNCOV
100
        if (! isset($this->provider)) {
×
101
            throw new AuthenticationException(lang('Auth.unknownOauthProvider', [$this->providerName ?? 'null']));
×
102
        }
103

104
        /** @var UserIdentityModel $identityModel */
UNCOV
105
        $identityModel = model(UserIdentityModel::class);
×
106

107
        $type = 'oauth_' . $this->providerName;
×
108

109
        /** @var UserIdentity|null $identity */
110
        $identity = $identityModel->where('user_id', $user->id)
×
UNCOV
111
            ->where('type', $type)
×
112
            ->first();
×
113

UNCOV
114
        if (! $identity || empty($identity->extra)) {
×
115
            return null;
×
116
        }
117

118
        try {
UNCOV
119
            $grant = new RefreshToken();
×
UNCOV
120
            $token = $this->provider->getAccessToken($grant, ['refresh_token' => $identity->extra]);
×
121

UNCOV
122
            $identity->secret2 = $token->getToken();
×
123

UNCOV
124
            if ($token->getRefreshToken()) {
×
UNCOV
125
                $identity->extra = $token->getRefreshToken();
×
126
            }
127

UNCOV
128
            if ($token->getExpires()) {
×
UNCOV
129
                $identity->expires = Time::createFromTimestamp($token->getExpires());
×
130
            }
131

UNCOV
132
            $identityModel->save($identity);
×
133

UNCOV
134
            return $token;
×
UNCOV
135
        } catch (IdentityProviderException) {
×
UNCOV
136
            return null;
×
137
        }
138
    }
139

140
    /**
141
     * Redirect the user to the OAuth provider's authorization page.
142
     */
143
    public function redirect(): RedirectResponse
144
    {
145
        $authorizationUrl = $this->provider->getAuthorizationUrl();
2✔
146
        session()->set('oauth2state', $this->provider->getState());
2✔
147

148
        return redirect()->to($authorizationUrl);
2✔
149
    }
150

151
    /**
152
     * Handle the authorization callback from the OAuth provider.
153
     */
154
    public function handleCallback(string $code, string $state): User
155
    {
156
        $sessionState = session()->get('oauth2state');
3✔
157

158
        if ($state === '' || $state === '0' || ($state !== $sessionState)) {
3✔
159
            session()->remove('oauth2state');
1✔
160

161
            throw new AuthenticationException(lang('Auth.invalidOauthState'));
1✔
162
        }
163

164
        session()->remove('oauth2state');
2✔
165

166
        try {
167
            $token       = $this->provider->getAccessToken('authorization_code', ['code' => $code]);
2✔
168
            $userProfile = $this->provider->getResourceOwner($token);
2✔
169

170
            return $this->processUser($userProfile, $token);
2✔
UNCOV
171
        } catch (IdentityProviderException $e) {
×
UNCOV
172
            throw new AuthenticationException($e->getMessage());
×
173
        }
174
    }
175

176
    /**
177
     * Extract normalised email, name, and social ID from a resource owner.
178
     *
179
     * Each League provider exposes data differently:
180
     *   - Google / Facebook / GitHub : getEmail(), getName()
181
     *   - Azure                      : claim('email') / claim('unique_name')
182
     *   - GenericProvider & others   : toArray() fallback
183
     *
184
     * @return array{id: string, email: string|null, name: string|null}
185
     */
186
    protected function extractUserData(ResourceOwnerInterface $resourceOwner): array
187
    {
188
        $id    = (string) $resourceOwner->getId();
2✔
189
        $email = null;
2✔
190
        $name  = null;
2✔
191

192
        if ($this->providerName === 'azure') {
2✔
193
            // Azure resource-owner uses claim() for user attributes
194
            /** @var AzureResourceOwner $resourceOwner */
UNCOV
195
            $email = $resourceOwner->claim('email') ?: $resourceOwner->claim('unique_name');
×
UNCOV
196
            $name  = $resourceOwner->claim('name');
×
197
        } else {
198
            // Standard League providers implement getEmail() / getName()
199
            if (method_exists($resourceOwner, 'getEmail')) {
2✔
UNCOV
200
                $email = $resourceOwner->getEmail();
×
201
            }
202

203
            if (method_exists($resourceOwner, 'getName')) {
2✔
UNCOV
204
                $name = $resourceOwner->getName();
×
205
            }
206

207
            // toArray() fallback for GenericProvider or providers without typed methods
208
            if ($email === null || $name === null) {
2✔
209
                $data = $resourceOwner->toArray();
2✔
210
                $email ??= $data['email'] ?? null;
2✔
211
                $name ??= $data['name'] ?? $data['login'] ?? null;
2✔
212
            }
213
        }
214

215
        return ['id' => $id, 'email' => $email, 'name' => $name];
2✔
216
    }
217

218
    /**
219
     * Find or create a local user and OAuth identity for the resource owner.
220
     */
221
    protected function processUser(ResourceOwnerInterface $userProfile, AccessTokenInterface $token): User
222
    {
223
        /** @var UserIdentityModel $identityModel */
224
        $identityModel = model(UserIdentityModel::class);
2✔
225

226
        $data     = $this->extractUserData($userProfile);
2✔
227
        $socialId = $data['id'];
2✔
228
        $email    = $data['email'];
2✔
229
        $name     = $data['name'];
2✔
230

231
        if (empty($email)) {
2✔
UNCOV
232
            throw new AuthenticationException(lang('Auth.emailNotFoundInOauth'));
×
233
        }
234

235
        $type     = 'oauth_' . $this->providerName;
2✔
236
        $identity = $identityModel->where('type', $type)
2✔
237
            ->where('secret', $socialId)
2✔
238
            ->first();
2✔
239

240
        $user = null;
2✔
241

242
        if ($identity) {
2✔
243
            /** @var UserIdentity $identity */
UNCOV
244
            $user = $identity->user();
×
245
        } else {
246
            $provider = auth()->getProvider();
2✔
247
            $user     = $provider->findByCredentials(['email' => $email]);
2✔
248

249
            if (! $user instanceof User) {
2✔
250
                $username = explode('@', $email)[0] . '_' . bin2hex(random_bytes(3));
1✔
251
                $user     = new User([
1✔
252
                    'username' => $username,
1✔
253
                    'active'   => true,
1✔
254
                ]);
1✔
255
                $provider->save($user);
1✔
256
                $user = $provider->findById($provider->getInsertID());
1✔
257

258
                $provider->addToDefaultGroup($user);
1✔
259
            }
260

261
            $identityModel->insert([
2✔
262
                'user_id' => $user->id,
2✔
263
                'type'    => $type,
2✔
264
                'name'    => $name ?? $email,
2✔
265
                'secret'  => $socialId,
2✔
266
                'secret2' => $token->getToken(),
2✔
267
                'extra'   => $token->getRefreshToken(),
2✔
268
                'expires' => $token->getExpires() ? Time::createFromTimestamp($token->getExpires()) : null,
2✔
269
            ]);
2✔
270
        }
271

272
        auth()->login($user);
2✔
273

274
        return $user;
2✔
275
    }
276
}
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