# Writing a Social Login Provider

https://dev.wisecp.com/es/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_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](https://dev.wisecp.com/en/module-anatomy), [Module Configuration](https://dev.wisecp.com/en/module-configuration).

## Structure

```bash
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.

```php
// 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()`.

```php
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'        => "ada@example.com",  // 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.

| 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. |

> **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 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
<?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,
            ],
        ];
    }
}
```

```php
// 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:

```php
// 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.

## Related Articles

- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Module Language Files](https://dev.wisecp.com/en/module-language-files)
- [Writing an Authentication Module](https://dev.wisecp.com/en/writing-an-authentication-module)
- [Login and Registration](https://dev.wisecp.com/en/login-and-registration)
- [Security Practices](https://dev.wisecp.com/en/security-practices)
