Writing a Social Login Provider

4 views Markdown

Add a "sign in with" button to the login, registration and admin screens. One class names the provider's endpoints and maps its claims to an account.

Overview

SocialAuth has a real base class. SocialAuthProvider owns the authorize URL, the CSRF state, the code exchange, token verification and the popup button. Your module declares five endpoints, a credential schema and one mapping method.

Three providers ship: Apple, Google, Microsoft. Discovery is a registry, not a hook: Auth::activeProviders() keeps the enabled modules, so a new folder appears on every sign screen.

The flow: button, popup, consent, one callback, code exchange, verification, account resolution, sign in.

Prerequisites

  • A provider speaking OAuth 2.0 code flow and OpenID Connect, with an RS256 signed id_token and a JWKS document.
  • A client id and secret, plus a registered redirect URI.
  • Outbound HTTPS to the token and JWKS endpoints.
  • Module Anatomy, Module Configuration.

Structure

file layout
coremio/modules/SocialAuth/Acme/
├── Acme.php          class Acme extends \SocialAuthProvider
├── config.php        meta + status + empty settings keys
├── acme.svg          brand logo, optional (fall back to a bi-* icon)
└── lang/
    ├── en.php
    └── tr.php
SocialAuthProvider The shared base.
Auth::activeProviders() The registry sign screens read.
Auth::handleProviderCallback() The callback entry point; consumes the state, calls feedback().
Auth::connectProvider() Resolves the account (provider id, then email) and signs in.
coremio/modules/SocialAuth Your folder goes here.

Walkthrough

1. Scaffold the Module

  1. Create coremio/modules/SocialAuth/Acme/.
  2. Write config.php: meta, status => false, an empty settings map.
  3. Write lang/en.php and lang/tr.php: button-label, a field-* pair per credential, error-*, setup steps.
  4. Add a colour logo as acme.svg, or return a bi-* icon name.

2. Endpoints and Credentials

  1. Extend the base and implement the five endpoint methods.
  2. Implement configFields(); a 'type' => 'password' field is encrypted and masked.
  3. Implement testConnection() so it asks the provider something real.
  4. Write the setup steps as setup-step-1, setup-step-2, contiguously.

3. Map the Identity

  1. Implement feedback(): exchange_code(), then verify_id_token().
  2. Reject an unverified email by throwing.
  3. Pick an identifier that is per account, not per application: Google and Apple use sub, Microsoft oid.
  4. Return the two-part array below.

4. Enable

  1. Find your provider in the Settings Social Auth list.
  2. Copy the Redirect URI the panel shows into the provider console.
  3. Paste the credentials, press Test Connection, switch it on and save.
  4. Reload the login page.

Reference

Abstract Contract

Nine abstract methods.

signatures
// Presentation. ['label' => string, 'icon' => 'bi-*', 'logo' => absolute url (optional)]
abstract public function provider_meta(): array;

// Credential schema for the Settings accordion. See the key table below.
abstract public function configFields(): array;

// The [Test Connection] button. Return true, or throw with a message the operator can act on.
abstract public function testConnection(): bool;

abstract protected function auth_endpoint(): string;    // where the popup sends the user
abstract protected function token_endpoint(): string;   // where the code is exchanged
abstract protected function scopes(): string;           // e.g. 'openid email profile'
abstract protected function jwks_url(): string;         // signature verification keys
abstract protected function issuers(): array;           // accepted `iss` values

// $ctx = ['code' => string, 'redirect_uri' => string, 'nonce' => string]
abstract public function feedback(array $ctx): array;

feedback()

The context array carries three keys.

code The authorization code, from the query string or POST body.
redirect_uri The URI the authorize step used.
nonce Planted in the authorize URL; pass it to verify_id_token().
return shape
return [
    // The account link. `name` becomes a user info key, `value` is the encrypted stable id.
    'field_info' => [
        'name'  => "acme_uid",
        'value' => Crypt::encode($sub, Config::get("crypt/user")),
    ],
    // What the account is built from / matched by.
    'data' => [
        'name'         => "Ada",              // given name
        'surname'      => "Lovelace",         // family name
        'email'        => "[email protected]",  // MUST be provider-verified
        'picture'      => "",                 // avatar url, or an empty string
        'provider_uid' => $sub,               // the raw id, unencrypted
    ],
];

configFields()

The returned map is field name => descriptor; the name is the POST name and the settings key.

type Absent means text; password is masked and encrypted, textarea is multi line.
label The visible label.
required Feeds the enabled state, not validation.
secret Encrypt and mask a non password field.
placeholder Hint text.
description Help text.
rows Height of a textarea.

Four Override Points

The defaults suit a plain OpenID Connect provider.

MethodDefaultOverride when
client_id()the stored client_idit lives under another key; it is also the audience.
client_secret()the decrypted client_secretno static secret; one is minted per request.
extra_auth_params()['prompt' => 'select_account']extra parameters are needed. Merge, do not replace.
accept_issuer($iss, $issuers, $payload)exact match against issuers()the issuer is not fixed, for example a tenant id.
Call the accessor, not the raw setting

The authorize URL, the code exchange and the audience check go through client_id(). Reading the setting directly makes an override ineffective.

Base Helpers

exchange_code() Posts the code. Throws without an id_token.
verify_id_token() Verifies signature, issuer, audience, expiry and nonce locally; returns the claims.
setting() One stored setting, raw; a secret stays encrypted.
callback_url() The single redirect URI; the starting side travels in the CSRF state.
enabled() True when the switch is on and every required credential is filled.
authorize_url($context, $mode) Builds the authorize URL and registers the state. $context: admin or client, $mode: login or register.
setup_guide() Collects setup-step-N to the first gap.
save_settings($fields) Persists the accordion; keeps the stored secret when the posted one is the mask.

Core Call Sites

Call siteCallsWhy
Auth::activeProviders()provider_meta(), enabled(), connection_button()Registry.
Auth::handleProviderCallback()enabled(), consume_state(), feedback()Callback; your message goes to the popup.
controllers/admin/settings.phpconfigFields(), callback_url(), setup_guide()Settings accordion.
save_social_provider()save_settings()Save.
test_social_provider()testConnection()Test; typed values apply first.

Example

A complete provider, then the core code that reads it.

Acme.php
<?php
namespace WISECP\Modules\SocialAuth;

use Config;
use Crypt;
use Exception;
use Filter;
use Utility;

class Acme extends \SocialAuthProvider
{
    public function provider_meta(): array
    {
        return [
            'label' => $this->lang["button-label"] ?? "Continue with Acme",
            'icon'  => 'bi-box-arrow-in-right',
            'logo'  => $this->url . 'acme.svg',
        ];
    }

    public function configFields(): array
    {
        return [
            'client_id' => [
                'type'        => 'text',
                'label'       => $this->lang["field-client-id"] ?? "Client ID",
                'required'    => true,
                'placeholder' => "acme-0000-0000",
                'description' => $this->lang["field-client-id-desc"] ?? '',
            ],
            'client_secret' => [
                'type'        => 'password',
                'label'       => $this->lang["field-client-secret"] ?? "Client Secret",
                'required'    => true,
                'description' => $this->lang["field-client-secret-desc"] ?? '',
            ],
        ];
    }

    protected function auth_endpoint(): string  { return "https://id.acme.example/oauth2/authorize"; }
    protected function token_endpoint(): string { return "https://id.acme.example/oauth2/token"; }
    protected function scopes(): string         { return "openid email profile"; }
    protected function jwks_url(): string       { return "https://id.acme.example/.well-known/jwks.json"; }
    protected function issuers(): array         { return ["https://id.acme.example"]; }

    public function testConnection(): bool
    {
        $clientId = $this->client_id();
        if ($clientId === '')
            throw new Exception($this->lang["error-invalid-client-id"] ?? "Please enter a Client ID.");

        // Ask the provider whether this id exists: an unknown one answers invalid_client,
        // a real one answers redirect_uri_mismatch. A format check would pass either way.
        $probe = $this->auth_endpoint() . '?' . http_build_query([
            'client_id'     => $clientId,
            'response_type' => 'code',
            'scope'         => $this->scopes(),
            'redirect_uri'  => $this->callback_url(),
            'state'         => 'wisecp-connectivity-check',
        ]);

        $resp = (string) Utility::HttpRequest($probe, ['timeout' => 8]);
        if ($resp === '')
            throw new Exception($this->lang["error-unreachable"] ?? "Could not reach Acme.");

        if (str_contains($resp, 'invalid_client'))
            throw new Exception($this->lang["error-client-not-found"] ?? "Acme does not recognize this Client ID.");

        return true;
    }

    public function feedback(array $ctx): array
    {
        $code = (string) ($ctx['code'] ?? '');
        if ($code === '') throw new Exception("Missing authorization code.");

        $token   = $this->exchange_code($code, (string) ($ctx['redirect_uri'] ?? ''));
        $payload = $this->verify_id_token((string) $token['id_token'], (string) ($ctx['nonce'] ?? ''));

        $email = (string) ($payload["email"] ?? '');
        if (!$email)
            throw new Exception($this->lang["error-no-email"] ?? "Could not read your email address.");

        // Fail closed: the resolver matches an existing account by email, so an
        // unverified address would let anyone claim someone else's account.
        if (($payload["email_verified"] ?? false) !== true)
            throw new Exception($this->lang["error-email-unverified"] ?? "Acme has not verified this email address.");

        $fullName = trim((string) ($payload["given_name"] ?? '') . ' ' . (string) ($payload["family_name"] ?? ''));
        $smash    = Filter::name_smash(Utility::ucfirst_space(Utility::substr($fullName, 0, 255)));

        $sub = (string) ($payload["sub"] ?? '');
        if ($sub === '') throw new Exception($this->lang["error-no-account"] ?? "Could not identify your Acme account.");

        return [
            'field_info' => [
                'name'  => "acme_uid",
                'value' => Crypt::encode($sub, Config::get("crypt/user")),
            ],
            'data' => [
                'name'         => $smash["first"] ?? '',
                'surname'      => $smash["last"] ?? '',
                'email'        => $email,
                'picture'      => (string) ($payload["picture"] ?? ''),
                'provider_uid' => $sub,
            ],
        ];
    }
}
core side, do not copy
// classes/Auth.php - handleProviderCallback(), reduced to the part your class touches.
$st = $provider->consume_state($state);            // mode + context + nonce, one shot
if (!$st) return ['status' => 'error', 'message' => "Sign-in could not be completed."];

$result = $provider->feedback([
    'code'         => $code,
    'redirect_uri' => $provider->callback_url(),
    'nonce'        => (string) ($st['nonce'] ?? ''),
]);

// connectProvider() then does, in this order:
//   1. look the account up by field_info (survives an email change),
//   2. fall back to data.email,
//   3. on mode=register and member context only, create the account,
//   4. persist field_info so the next sign-in resolves by step 1.
return Auth::connectProvider('member', 'Acme', $result, (string) ($st['mode'] ?? 'login'));

The sign screen asks the registry:

the button on the page
// controllers/website/sign.php - the login page, and the same call with "register" on sign-up.
$this->addData("social_providers", \Auth::activeProviders("login", "client"));

// The theme then loops the array; each entry is ['meta' => [...], 'button' => '<html>'].

Pitfalls

An unverified email is an account takeover

The resolver matches by email, so an unverified address lets an attacker sign in as somebody else.

The wrong stable id breaks the second sign-in

Pick a claim that is immutable and identical across applications. A pairwise identifier links the account to a value that never comes back.

Report failure by throwing

$this->error = '...'; return false; is a leftover. The callback handler and the Settings operations catch the exception and show its message.

Do not put a secret in config.php by hand

Secrets are written encrypted and read through the matching decode; a hand typed value becomes an empty string.

Build the instance with the factory

Every core call site uses Modules::getInstance("SocialAuth", $name), which fills config and language. A direct constructor skips that.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.