Skip to content

OAuth 2.0 & Social Login

Daycry Auth integrates with PHP League's OAuth2 Client to support social login via external providers. Users can authenticate with Google, GitHub, Facebook, Microsoft/Azure, or any standard OAuth2/OIDC provider.

Table of Contents


How It Works

User clicks "Login with Google"
        |
Redirected to Google OAuth consent screen
        |
User authorizes -> Google redirects back to /oauth/google/callback
        |
System retrieves the user's email (and verified flag) from Google
        |
If a matching OAuth identity exists -> update tokens and log in
If the email matches an EXISTING password account -> link only if the
    provider asserts the email is verified (see Account Linking below)
If email is new -> create user account, link identity, log in
        |
Tokens + profile data stored in auth_users_identities
        |
Events fired: oauth-login (always), oauth-profile-fetched (if fields configured)

The OAuth identity is stored in auth_users_identities with type oauth_{provider} (e.g., oauth_google, oauth_github). One user can have multiple OAuth providers linked.


Architecture

The OAuth subsystem is composed of several focused classes:

Class Responsibility
OauthManager Orchestrates the OAuth flow: redirect, callback, token refresh
OAuthTokenRepository All OAuth identity CRUD (find, create, update, parse extra)
ProfileResolverFactory Creates the appropriate profile resolver for a provider
AzureProfileResolver Azure-specific: uses Microsoft Graph API for profile fields
GenericProfileResolver Default: extracts fields from toArray() or a custom endpoint
IdentityType::oauthProvider() Builds the oauth_{name} type string (replaces manual concatenation)

OauthManager delegates all identity persistence to OAuthTokenRepository, following the same pattern as AccessTokenRepository and JwtTokenRepository.


Installation

Start with the base package:

composer require league/oauth2-client

Then install provider-specific packages (only the ones you need):

composer require league/oauth2-google    # Google
composer require league/oauth2-github    # GitHub
composer require league/oauth2-facebook  # Facebook
composer require thenetworg/oauth2-azure  # Microsoft Azure

Configuration

Add your providers in app/Config/AuthOAuth.php:

<?php

namespace Config;

use Daycry\Auth\Config\AuthOAuth as BaseAuthOAuth;

class AuthOAuth extends BaseAuthOAuth
{
    public array $providers = [

        'google' => [
            'clientId'     => env('OAUTH_GOOGLE_CLIENT_ID'),
            'clientSecret' => env('OAUTH_GOOGLE_CLIENT_SECRET'),
            'redirectUri'  => 'https://yourapp.com/oauth/google/callback',
            'scopes'       => ['openid', 'email', 'profile'],
        ],

        'github' => [
            'clientId'     => env('OAUTH_GITHUB_CLIENT_ID'),
            'clientSecret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
            'redirectUri'  => 'https://yourapp.com/oauth/github/callback',
            'scopes'       => ['user:email'],
        ],

        'facebook' => [
            'clientId'     => env('OAUTH_FACEBOOK_APP_ID'),
            'clientSecret' => env('OAUTH_FACEBOOK_APP_SECRET'),
            'redirectUri'  => 'https://yourapp.com/oauth/facebook/callback',
            'graphApiVersion' => 'v12.0',
            'scopes'       => ['email', 'public_profile'],
        ],

        'azure' => [
            'clientId'     => env('OAUTH_AZURE_CLIENT_ID'),
            'clientSecret' => env('OAUTH_AZURE_CLIENT_SECRET'),
            'redirectUri'  => 'https://yourapp.com/oauth/azure/callback',
            'tenant'       => 'common',   // 'common', 'organizations', or a tenant GUID
            'scopes'       => ['openid', 'profile', 'email', 'offline_access'],
        ],

    ];
}

Provider Configuration Keys

Each provider entry supports these keys:

Key Required Description
clientId Yes OAuth client ID
clientSecret Yes OAuth client secret
redirectUri Yes Callback URL for the provider
scopes No OAuth scopes to request
fields No Extra profile fields to fetch (see Profile Fields)
fieldsEndpoint No Custom API endpoint for profile fields
profileResolver No Custom profile resolver class (see Custom Profile Resolver)
allowUnverifiedEmailLink No Opt-in (default unset = false). Allow auto-linking to an existing password account even when the provider cannot assert the email is verified (see Account Linking & Email Verification)
tenant Azure only Azure AD tenant: 'common', 'organizations', or a tenant GUID

Security: Always load credentials from environment variables, never hardcode them.


Routing

Automatic Routing

If you use auth()->routes($routes) in app/Config/Routes.php, OAuth routes are registered automatically.

Manual Routing

// app/Config/Routes.php
$routes->group('oauth', ['namespace' => 'Daycry\Auth\Controllers'], static function ($routes) {
    // Initiates the redirect to the provider (sign in / sign up)
    $routes->get('login/(:segment)',    'OauthController::redirect/$1',  ['as' => 'oauth-login']);
    // Handles the callback from the provider (shared by sign-in and link flows)
    $routes->get('callback/(:segment)', 'OauthController::callback/$1', ['as' => 'oauth-callback']);
    // Links a provider to the *already logged-in* user (see Explicit Linking)
    $routes->get('link/(:segment)',     'OauthController::link/$1',     ['as' => 'oauth-link']);
});
Route Named Route Description
GET /oauth/login/{provider} oauth-login Redirects user to provider (sign in / sign up)
GET /oauth/callback/{provider} oauth-callback Handles the return from provider
GET /oauth/link/{provider} oauth-link Links a provider to the currently authenticated user (see Explicit Linking for Logged-In Users)

Adding Login Buttons

<!-- Google -->
<a href="<?= url_to('oauth-login', 'google') ?>" class="btn btn-light border">
    <img src="/assets/img/google-logo.svg" width="20" class="me-2">
    Continue with Google
</a>

<!-- GitHub -->
<a href="<?= url_to('oauth-login', 'github') ?>" class="btn btn-dark">
    <i class="bi bi-github me-2"></i>Continue with GitHub
</a>

<!-- Facebook -->
<a href="<?= url_to('oauth-login', 'facebook') ?>" class="btn btn-primary">
    <i class="bi bi-facebook me-2"></i>Continue with Facebook
</a>

<!-- Microsoft -->
<a href="<?= url_to('oauth-login', 'azure') ?>" class="btn btn-secondary">
    <img src="/assets/img/ms-logo.svg" width="20" class="me-2">
    Continue with Microsoft
</a>

Provider Examples

Google

composer require league/oauth2-google

Google Cloud Console setup: 1. Go to console.cloud.google.com 2. Create a project -> Enable "Google+ API" or "People API" 3. Credentials -> Create OAuth 2.0 Client ID (Web application) 4. Add https://yourapp.com/oauth/google/callback to Authorized redirect URIs

'google' => [
    'clientId'     => env('OAUTH_GOOGLE_CLIENT_ID'),
    'clientSecret' => env('OAUTH_GOOGLE_CLIENT_SECRET'),
    'redirectUri'  => 'https://yourapp.com/oauth/google/callback',
    'scopes'       => ['openid', 'email', 'profile'],
    'hostedDomain' => 'yourcompany.com', // Optional: restrict to one domain
],

GitHub

composer require league/oauth2-github

GitHub setup: 1. GitHub -> Settings -> Developer Settings -> OAuth Apps -> New OAuth App 2. Set Homepage URL and Authorization callback URL

'github' => [
    'clientId'     => env('OAUTH_GITHUB_CLIENT_ID'),
    'clientSecret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
    'redirectUri'  => 'https://yourapp.com/oauth/github/callback',
    'scopes'       => ['user:email'],
],

Facebook

composer require league/oauth2-facebook

Facebook setup: 1. Go to developers.facebook.com 2. Create App -> Add "Facebook Login" product 3. Set Valid OAuth Redirect URIs

'facebook' => [
    'clientId'        => env('OAUTH_FACEBOOK_APP_ID'),
    'clientSecret'    => env('OAUTH_FACEBOOK_APP_SECRET'),
    'redirectUri'     => 'https://yourapp.com/oauth/facebook/callback',
    'graphApiVersion' => 'v18.0',
    'scopes'          => ['email', 'public_profile'],
],

Microsoft / Azure

composer require thenetworg/oauth2-azure

Azure setup: 1. Go to portal.azure.com -> Azure Active Directory -> App registrations 2. New registration -> set Redirect URI to your callback URL 3. Add a client secret under Certificates & secrets

'azure' => [
    'clientId'     => env('OAUTH_AZURE_CLIENT_ID'),
    'clientSecret' => env('OAUTH_AZURE_CLIENT_SECRET'),
    'redirectUri'  => 'https://yourapp.com/oauth/azure/callback',
    // 'common'        = personal + work accounts
    // 'organizations' = work accounts only
    // '{tenant-guid}' = specific tenant only
    'tenant'  => 'common',
    'scopes'  => ['openid', 'profile', 'email', 'offline_access', 'User.Read'],
    // Optional: fetch extra fields from Microsoft Graph
    'fields'  => ['department', 'jobTitle', 'officeLocation', 'mobilePhone'],
],

The urlAuthorize and urlAccessToken are auto-constructed from the tenant value.


Generic OIDC Provider

For providers that follow OpenID Connect standards:

'myoidc' => [
    'clientId'                => env('OIDC_CLIENT_ID'),
    'clientSecret'            => env('OIDC_CLIENT_SECRET'),
    'redirectUri'             => 'https://yourapp.com/oauth/myoidc/callback',
    'urlAuthorize'            => 'https://idp.example.com/authorize',
    'urlAccessToken'          => 'https://idp.example.com/token',
    'urlResourceOwnerDetails' => 'https://idp.example.com/userinfo',
    'scopes'                  => ['openid', 'email', 'profile'],
    'fields'                  => ['role', 'team'],
    'fieldsEndpoint'          => 'https://api.example.com/userinfo',
    // 'profileResolver'      => \App\OAuth\MyCustomResolver::class,
],

Account Linking & Email Verification

When a social login arrives, OauthManager::processUser() decides how to map it to a local account. The three cases (in order) are:

  1. Known social identity -- an auth_users_identities row already exists for this provider + social ID. The stored tokens and profile are refreshed and the user is logged in. No email check is involved.
  2. Email matches an EXISTING local account -- no OAuth identity exists yet, but a local (password) account already uses the same email. This is the sensitive case (see below): the merge is only auto-performed when the provider asserts the email is verified.
  3. Brand-new email -- no identity and no matching account, so a new user is created, assigned to the default group, and logged in.

Verified-Email Requirement

Case 2 above is the classic unverified-email account-takeover vector: an attacker registers a social account at a provider that does not verify email ownership, sets the email to the victim's address, and -- if the library blindly merged on email -- would be silently logged in as the victim.

To prevent this, Daycry Auth links a social account to an existing password account only when the provider tells us the email is verified. The verified signal is read from the resource owner during extractUserData():

Provider type Verified signal read Asserts verification?
Generic OIDC email_verified claim Yes, when the IdP sets it
Google email_verified / legacy verified_email Yes
Microsoft / Azure email_verified claim Yes, when present
Facebook (none exposed) No
GitHub (none exposed) No

The flag is normalised from the provider's inconsistent representations (true, 1, "1", "true") into a strict boolean.

If the provider does not assert a verified email and the matching local account exists, the merge is refused:

throw new AuthenticationException(lang('Auth.oauthEmailUnverified'));
// "This email address is already registered.
//  Sign in with your password to link this provider."

This guard applies only to case 2 (linking to a pre-existing password account). Case 1 (re-login of a known social identity) and case 3 (a brand-new account) are unaffected -- there is no existing account to take over.

The recommended recovery path for the user is exactly what the message says: sign in with their password first, then attach the provider deliberately via the explicit linking flow.

If you fully trust a provider's email even though it does not send a verified flag, you can opt in per provider with allowUnverifiedEmailLink:

Option Default Meaning
allowUnverifiedEmailLink unset (= false) When true, auto-link a social account to an existing password account even if the provider does not assert the email is verified. When false/unset, such a merge is refused with lang('Auth.oauthEmailUnverified').
// app/Config/AuthOAuth.php
'github' => [
    'clientId'     => env('OAUTH_GITHUB_CLIENT_ID'),
    'clientSecret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
    'redirectUri'  => 'https://yourapp.com/oauth/github/callback',
    'scopes'       => ['user:email'],
    // GitHub does not expose a verified-email flag. Only enable this if you
    // accept the account-takeover risk for accounts that already have a password.
    'allowUnverifiedEmailLink' => true,
],

Security: Leave this unset (the secure default) unless you fully trust the provider. Providers that do assert verification (Google, generic OIDC, Azure) do not need it for verified emails. The most common reason to enable it is Facebook or GitHub, neither of which exposes a verified-email signal to the OAuth client.


Explicit Linking for Logged-In Users

Because auto-linking by email is deliberately restricted (above), Daycry Auth provides an explicit linking flow for users who are already signed in. This lets an authenticated user attach an additional provider to their own account without any email-matching heuristics.

The flow is handled by OauthController::link() and the oauth-link route:

Route Named Route Description
GET /oauth/link/{provider} oauth-link Begins linking {provider} to the currently authenticated user

How It Differs From Sign-In

oauth-login (sign in) oauth-link (explicit link)
Requires an authenticated user No Yes (redirects to the login page if not)
Email-merge to existing account Yes (case 2, guarded) Never -- links to the current user directly
Verified-email requirement Yes, for case 2 None -- the user is already authenticated and acting deliberately
Who gets linked The user resolved by social ID / email The current logged-in user

link() stashes the current user's id in the session (oauth_link_user_id) and redirects to the provider. The shared callback (oauth-callback) detects the stashed id and routes through the explicit-link path instead of the sign-in path, then clears the session key.

Adding a "Connect" Button

<!-- On the user's account / security settings page -->
<a href="<?= url_to('oauth-link', 'github') ?>" class="btn btn-dark">
    <i class="bi bi-github me-2"></i>Connect GitHub
</a>

Already-Linked-Elsewhere Refusal

If the chosen social account is already bound to a different local user, linking is refused (you cannot steal another user's social identity by linking it to your own account):

throw new AuthenticationException(lang('Auth.oauthAlreadyLinked'));
// "This account is already linked to a different user."

If the social account is already linked to the current user, the link is treated as a no-op that simply refreshes the stored token and profile.


Stored Token Data

After authentication, the following is stored in auth_users_identities:

Column Contains
type oauth_{provider} (e.g., oauth_google)
secret Provider's social user ID
secret2 Access token
extra JSON object (see below)
expires Access token expiry timestamp

Extra JSON Structure

The extra column stores a JSON object with the following fields:

{
    "refresh_token": "rt_abc123...",
    "scopes_granted": ["openid", "profile", "email"],
    "profile": {
        "department": "Engineering",
        "jobTitle": "Senior Developer"
    },
    "profile_fetched_at": "2026-03-20 14:30:00"
}
Field Present when Description
refresh_token Provider issues one OAuth refresh token for offline access
scopes_granted Token includes scope value Array of scopes the provider actually granted (RFC 6749 SS3.3)
profile fields is configured Extra profile data from the provider
profile_fetched_at profile is present Timestamp when the profile data was last fetched

Backward compatibility: Legacy identities that stored the refresh token as a plain string (not JSON) in extra are handled transparently. OAuthTokenRepository::parseExtra() detects the format and normalises it.


Profile Fields

Daycry Auth can fetch additional profile data from the provider beyond the standard email and name. This is useful for syncing organisational attributes like department, job title, or custom claims.

Configuring Profile Fields

Add a fields array to any provider configuration:

'azure' => [
    'clientId'     => env('OAUTH_AZURE_CLIENT_ID'),
    'clientSecret' => env('OAUTH_AZURE_CLIENT_SECRET'),
    'redirectUri'  => 'https://yourapp.com/oauth/azure/callback',
    'tenant'       => 'your-tenant-guid',
    'scopes'       => ['openid', 'profile', 'email', 'offline_access', 'User.Read'],
    // Fetch these fields from Microsoft Graph on login
    'fields'       => ['department', 'jobTitle', 'officeLocation', 'mobilePhone'],
],

When fields is set, OauthManager calls the appropriate profile resolver after the OAuth callback. The resolved data is stored in the extra JSON under the profile key, along with a profile_fetched_at timestamp.

If the profile fetch fails (network error, permission denied, etc.), login still succeeds — the error is logged as a warning.

Profile Resolvers

Profile resolvers extract field data from the provider. The ProfileResolverFactory chooses the resolver in this order:

  1. Config-based: If $providerConfig['profileResolver'] is set, that class is used (must implement ProfileResolverInterface)
  2. Built-in map: azure -> AzureProfileResolver
  3. Fallback: GenericProfileResolver

AzureProfileResolver

For Azure, the resolver calls the Microsoft Graph API (https://graph.microsoft.com/v1.0/me) to fetch the requested fields. Requires the User.Read scope.

GenericProfileResolver

The generic resolver works for any provider. It tries two strategies in order:

  1. Custom endpoint: If fieldsEndpoint is configured, it fetches data from that URL using the access token and filters the response to only the requested fields.
  2. Resource owner data: If no endpoint is configured (or it fails), it uses $resourceOwner->toArray() and filters the fields.

Custom Profile Resolver

To implement a custom resolver for a specific provider:

<?php

namespace App\OAuth;

use Daycry\Auth\Libraries\Oauth\ProfileResolver\ProfileResolverInterface;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use League\OAuth2\Client\Token\AccessTokenInterface;

class MyCustomResolver implements ProfileResolverInterface
{
    public function fetchFields(
        AbstractProvider $provider,
        AccessTokenInterface $token,
        ResourceOwnerInterface $resourceOwner,
        array $fields,
        array $config = [],
    ): array {
        // Custom logic to fetch profile fields
        $request  = $provider->getAuthenticatedRequest('GET', 'https://api.myprovider.com/user/profile', $token);
        $response = $provider->getParsedResponse($request);

        if (! is_array($response)) {
            return [];
        }

        // Return only the requested fields
        return array_intersect_key($response, array_flip($fields));
    }
}

Register it in the provider config:

'my_provider' => [
    'clientId'        => env('MY_PROVIDER_CLIENT_ID'),
    'clientSecret'    => env('MY_PROVIDER_CLIENT_SECRET'),
    'redirectUri'     => 'https://yourapp.com/oauth/my_provider/callback',
    'fields'          => ['role', 'team', 'avatar_url'],
    'profileResolver' => \App\OAuth\MyCustomResolver::class,
],

If the class does not implement ProfileResolverInterface, a LogicException is thrown at runtime.

Reading Stored Profile Data

use Daycry\Auth\Libraries\Oauth\OauthManager;

$user    = auth()->user();
$manager = new OauthManager(config('AuthOAuth'));
$manager->setProvider('azure');

$profile = $manager->getProfileData($user);
// Returns: ['department' => 'Engineering', 'jobTitle' => 'Senior Dev', ...]

// Or use the repository directly:
use Daycry\Auth\Models\OAuthTokenRepository;
use Daycry\Auth\Models\UserIdentityModel;

$repo    = new OAuthTokenRepository(model(UserIdentityModel::class));
$profile = $repo->getProfileData((int) $user->id, 'azure');

Scopes Granted

When the OAuth provider returns the granted scopes in the token response (as per RFC 6749 SS3.3), Daycry Auth stores them in the extra JSON as scopes_granted:

// After login, check what scopes were actually granted
$repo     = new OAuthTokenRepository(model(UserIdentityModel::class));
$identity = $repo->findByUserAndProvider((int) $user->id, 'azure');
$extra    = $repo->parseExtra($identity->extra);

$scopes = $extra['scopes_granted'] ?? [];
// ['openid', 'profile', 'email', 'User.Read']

This is useful when the provider may grant fewer scopes than requested (e.g., the user declined a specific permission).

Scopes are also updated when a token is refreshed via refreshAccessToken().


Refresh Tokens

Some providers (Azure with offline_access, Google with access_type=offline) issue refresh tokens that let you make API calls on behalf of users without requiring them to re-authenticate.

Refresh an Access Token

use Daycry\Auth\Libraries\Oauth\OauthManager;

$user    = auth()->user();
$manager = new OauthManager(config('AuthOAuth'));
$manager->setProvider('azure');

$newToken = $manager->refreshAccessToken($user);

if ($newToken !== null) {
    $accessToken  = $newToken->getToken();
    $refreshToken = $newToken->getRefreshToken();
    $expires      = $newToken->getExpires();

    // Make an API call with the fresh token
} else {
    // Refresh failed (no identity, no refresh token, or provider error)
    return redirect()->route('login');
}

When a token is refreshed: - The new access token replaces the old one in secret2 - If the provider rotates the refresh token, the new one is stored - If the refreshed token includes scopes, scopes_granted is updated - The existing profile and profile_fetched_at are preserved (no re-fetch on refresh)

Error Handling

refreshAccessToken() returns null in these cases: - No OAuth identity found for the user/provider - The identity has no extra data or no refresh_token in the extra - The provider rejects the refresh (e.g., token revoked, expired)

The method catches IdentityProviderException internally and returns null rather than throwing.


OAuth Events

OauthManager::handleCallback() fires two events after a successful OAuth login:

Event When Arguments
oauth-login Always, after login User $user, string $providerName
oauth-profile-fetched When profile fields were resolved User $user, string $providerName, array $profileData

Listening to OAuth Events

// app/Config/Events.php
use CodeIgniter\Events\Events;

// Log all OAuth logins
Events::on('oauth-login', static function (object $user, string $provider): void {
    log_message('info', "OAuth login via {$provider} for user {$user->email}");
});

// Sync profile data to local tables
Events::on('oauth-profile-fetched', static function (object $user, string $provider, array $profileData): void {
    if (isset($profileData['department'])) {
        // Update user's department in a custom table
        db_connect()->table('user_profiles')->upsert([
            'user_id'    => $user->id,
            'department' => $profileData['department'],
            'updated_at' => date('Y-m-d H:i:s'),
        ], ['user_id']);
    }
});

// Alert on first-time OAuth logins
Events::on('oauth-login', static function (object $user, string $provider): void {
    $identityCount = model(\Daycry\Auth\Models\UserIdentityModel::class)
        ->where('user_id', $user->id)
        ->like('type', 'oauth_', 'after')
        ->countAllResults();

    if ($identityCount === 1) {
        // First OAuth link — send notification
        log_message('info', "New OAuth account linked: {$provider} for {$user->email}");
    }
});

OAuthTokenRepository

OAuthTokenRepository encapsulates all OAuth identity CRUD, following the same pattern as AccessTokenRepository and JwtTokenRepository. It wraps UserIdentityModel and uses IdentityType::oauthProvider() for type strings.

Available Methods

Method Description
findByUserAndProvider(int $userId, string $provider) Find the OAuth identity for a user/provider pair
findByProviderAndSocialId(string $provider, string $socialId) Find by provider type and social ID
createOAuthIdentity(int $userId, string $provider, array $data) Insert a new OAuth identity row
updateOAuthIdentity(UserIdentity $identity) Update an existing identity (token refresh, re-login)
getProfileData(int $userId, string $provider) Get the stored profile data from the extra JSON
parseExtra(?string $extra) Parse the extra column (handles JSON and legacy plain-string format)

Direct Usage

use Daycry\Auth\Models\OAuthTokenRepository;
use Daycry\Auth\Models\UserIdentityModel;

$repo = new OAuthTokenRepository(model(UserIdentityModel::class));

// Find an OAuth identity
$identity = $repo->findByUserAndProvider((int) $user->id, 'google');

if ($identity !== null) {
    $extra = $repo->parseExtra($identity->extra);
    $accessToken  = $identity->secret2;
    $refreshToken = $extra['refresh_token'] ?? null;
    $scopes       = $extra['scopes_granted'] ?? [];
    $profile      = $extra['profile'] ?? [];
}

// Get just the profile data
$profile = $repo->getProfileData((int) $user->id, 'azure');
// ['department' => 'Engineering', 'jobTitle' => 'Senior Dev']

OauthManager uses the repository internally via a lazy-initialised getter. You generally don't need to use it directly unless building custom OAuth integrations.


IdentityType Helper

OAuth identity types are dynamic (oauth_google, oauth_github, etc.) because the set of providers is user-defined. Instead of concatenating strings manually, use the static helper:

use Daycry\Auth\Enums\IdentityType;

// Instead of: 'oauth_' . $providerName
$type = IdentityType::oauthProvider('google');  // 'oauth_google'
$type = IdentityType::oauthProvider('azure');   // 'oauth_azure'

This centralises the oauth_ prefix convention and makes OAuth type strings grep-able across the codebase.


Unlinking a Provider

Users can disconnect a social login from their account. Daycry Auth ships with UserSecurityController::unlinkOauth() for this.

Route Setup

$routes->post('security/oauth/unlink/(:segment)',
    'Daycry\Auth\Controllers\UserSecurityController::unlinkOauth/$1',
    ['filter' => 'auth:session', 'as' => 'oauth-unlink']);
<?php foreach ($linkedProviders as $provider): ?>
    <div class="d-flex justify-content-between align-items-center border rounded p-3 mb-2">
        <span><?= esc(ucfirst($provider)) ?></span>
        <?= form_open('security/oauth/unlink/' . esc($provider), ['method' => 'post']) ?>
            <button type="submit" class="btn btn-sm btn-outline-danger"
                    onclick="return confirm('Disconnect <?= esc(ucfirst($provider)) ?>?')">
                Disconnect
            </button>
        <?= form_close() ?>
    </div>
<?php endforeach ?>

Safety Check

The unlink logic ensures users always retain at least one way to sign in. It will refuse to unlink the last identity if the user has no password set.


Account Linking Strategy

When a user authenticates via OAuth, the system:

  1. Looks up by social ID -- if an OAuth identity with the same provider and social ID already exists, the tokens and profile are updated (re-login)
  2. Finds the user by email -- if no OAuth identity exists but an account with that email does, the OAuth identity is linked to it only when the provider asserts the email is verified (or allowUnverifiedEmailLink is enabled for that provider). Otherwise the merge is refused with lang('Auth.oauthEmailUnverified'). See Account Linking & Email Verification.
  3. Creates a new account -- if no matching email is found, a new user is created automatically and assigned to the default group
  4. Stores the provider identity -- the OAuth provider ID, tokens, scopes, and profile data are saved in auth_users_identities

Already-authenticated users can attach a provider to their own account without any email matching via the explicit linking flow (oauth-link).

Handling New Users from OAuth

Listen to the oauth-login event to run onboarding logic for users who sign up via OAuth:

// app/Config/Events.php
use CodeIgniter\Events\Events;

Events::on('oauth-login', static function (object $user, string $provider): void {
    // Check if this is a brand new user (no other identities)
    // The default group is assigned automatically during OAuth registration.
});

Testing OAuth

Mocking the Provider

OauthManager::setProviderInstance() accepts a mock provider for testing:

use Daycry\Auth\Libraries\Oauth\OauthManager;
use Daycry\Auth\Config\AuthOAuth;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\GenericResourceOwner;
use League\OAuth2\Client\Token\AccessToken;
use Mockery;

$provider    = Mockery::mock(AbstractProvider::class);
$accessToken = new AccessToken([
    'access_token'  => 'test_token',
    'refresh_token' => 'refresh_abc',
    'scope'         => 'openid profile email',
]);

$provider->shouldReceive('getAccessToken')
    ->with('authorization_code', ['code' => 'auth_code'])
    ->andReturn($accessToken);

$resourceOwner = Mockery::mock(GenericResourceOwner::class);
$resourceOwner->shouldReceive('getId')->andReturn('social_123');
$resourceOwner->shouldReceive('toArray')->andReturn([
    'email' => 'test@example.com',
    'name'  => 'Test User',
    'role'  => 'admin',
]);

$provider->shouldReceive('getResourceOwner')
    ->with($accessToken)
    ->andReturn($resourceOwner);

$manager = new OauthManager(new AuthOAuth());
$manager->setProviderInstance($provider, 'generic');

session()->set('oauth2state', 'valid_state');
$user = $manager->handleCallback('auth_code', 'valid_state');

Testing Events

use CodeIgniter\Events\Events;

$triggered = false;
Events::on('oauth-login', static function ($user, $providerName) use (&$triggered): void {
    $triggered = true;
});

// ... perform OAuth callback ...

$this->assertTrue($triggered);

Testing the Repository

use Daycry\Auth\Models\OAuthTokenRepository;
use Daycry\Auth\Models\UserIdentityModel;
use Daycry\Auth\Enums\IdentityType;

$repo = new OAuthTokenRepository(model(UserIdentityModel::class));

// Test parseExtra with JSON
$result = $repo->parseExtra('{"refresh_token": "rt", "profile": {"a": 1}}');
$this->assertSame('rt', $result['refresh_token']);

// Test parseExtra with legacy string
$result = $repo->parseExtra('plain_token_string');
$this->assertSame(['refresh_token' => 'plain_token_string'], $result);

// Test parseExtra with null/empty
$this->assertSame([], $repo->parseExtra(null));
$this->assertSame([], $repo->parseExtra(''));

Testing the ProfileResolverFactory

use Daycry\Auth\Libraries\Oauth\ProfileResolver\ProfileResolverFactory;
use Daycry\Auth\Libraries\Oauth\ProfileResolver\AzureProfileResolver;
use Daycry\Auth\Libraries\Oauth\ProfileResolver\GenericProfileResolver;

// Built-in map
$this->assertInstanceOf(AzureProfileResolver::class, ProfileResolverFactory::create('azure'));

// Fallback
$this->assertInstanceOf(GenericProfileResolver::class, ProfileResolverFactory::create('unknown'));

// Config-based override
$resolver = ProfileResolverFactory::create('azure', [
    'profileResolver' => GenericProfileResolver::class,
]);
$this->assertInstanceOf(GenericProfileResolver::class, $resolver);

// Invalid resolver throws LogicException
$this->expectException(\CodeIgniter\Exceptions\LogicException::class);
ProfileResolverFactory::create('test', ['profileResolver' => \stdClass::class]);

See also: - Authentication -- All authentication methods - Configuration -- Full $providers reference - Controllers -- Custom OAuth controller patterns - UserSecurityController -- Unlink and change email - Logging & Monitoring -- OAuth events and logging - Testing -- Complete testing guide