Writing a Social Login Provider
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_tokenand 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
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
feedback().
Walkthrough
1. Scaffold the Module
- Create
coremio/modules/SocialAuth/Acme/. - Write
config.php:meta,status => false, an emptysettingsmap. - Write
lang/en.phpandlang/tr.php:button-label, afield-*pair per credential,error-*, setup steps. - Add a colour logo as
acme.svg, or return abi-*icon name.
2. Endpoints and Credentials
- Extend the base and implement the five endpoint methods.
- Implement
configFields(); a'type' => 'password'field is encrypted and masked. - Implement
testConnection()so it asks the provider something real. - Write the setup steps as
setup-step-1,setup-step-2, contiguously.
3. Map the Identity
- Implement
feedback():exchange_code(), thenverify_id_token(). - Reject an unverified email by throwing.
- Pick an identifier that is per account, not per application: Google and Apple use
sub, Microsoftoid. - Return the two-part array below.
4. Enable
- Find your provider in the Settings Social Auth list.
- Copy the Redirect URI the panel shows into the provider console.
- Paste the credentials, press Test Connection, switch it on and save.
- Reload the login page.
Reference
Abstract Contract
Nine abstract methods.
// 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.
verify_id_token().
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.
password is masked and encrypted, textarea is multi line.
password field.
textarea.
Four Override Points
The defaults suit a plain OpenID Connect provider.
| Method | Default | Override when |
|---|---|---|
client_id() | the stored client_id | it lives under another key; it is also the audience. |
client_secret() | the decrypted client_secret | no 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. |
The authorize URL, the code exchange and the audience check go through client_id(). Reading the setting directly makes an override ineffective.
Base Helpers
id_token.
$context: admin or client, $mode: login or register.
setup-step-N to the first gap.
Core Call Sites
| Call site | Calls | Why |
|---|---|---|
Auth::activeProviders() | provider_meta(), enabled(), connection_button() | Registry. |
Auth::handleProviderCallback() | enabled(), consume_state(), feedback() | Callback; your message goes to the popup. |
controllers/admin/settings.php | configFields(), 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.
<?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,
],
];
}
}
// 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:
// 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
The resolver matches by email, so an unverified address lets an attacker sign in as somebody else.
Pick a claim that is immutable and identical across applications. A pairwise identifier links the account to a value that never comes back.
$this->error = '...'; return false; is a leftover. The callback handler and the Settings operations catch the exception and show its message.
Secrets are written encrypted and read through the matching decode; a hand typed value becomes an empty string.
Every core call site uses Modules::getInstance("SocialAuth", $name), which fills config and language. A direct constructor skips that.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.