SDK & API

Documentation développeurs

Intégrer Switch Connect et consommer l’API compte. Distinct du catalogue OpenAPI interactif.

Switch Connect - Documentation API développeurs

Intégrer « Se connecter avec Switch Compagnie » (OAuth 2.0 / OpenID Connect) dans une application tierce, avec un exemple complet en Laravel.

Rédigé à partir du code de SwitchCompagnie-Main-Web (main @ fc34a71, Laravel 13, laravel/passport v13.9.0) et vérifié en lecture seule sur la prod le 11/10/2026. Les points non déterminables depuis le code sont marqués « à confirmer ».


Sommaire

  1. Présentation
  2. Créer un client OAuth
  3. Flux OAuth 2.0 Authorization Code (+ PKCE)
  4. Endpoints API
  5. Intégration Laravel pas à pas
  6. Sécurité
  7. FAQ / dépannage
  8. Annexe : sources dans le code

1. Présentation

Switch Connect est le fournisseur d'identité de Switch Compagnie. Une application tierce (PrintoLab, un outil interne, l'app d'un partenaire…) peut ainsi :

  • authentifier un utilisateur avec son compte Switch Compagnie ;
  • lire son identité (claims OpenID Connect selon les scopes) ;
  • appeler l'API Switch Compagnie en son nom, par exemple pour lire ses factures, en créer ou les payer avec son portefeuille si les scopes billing* sont accordés.

Techniquement, c'est Laravel Passport (serveur OAuth 2.0) avec une surcouche OpenID Connect « légère » :

| Élément | Présent | Remarque | |---|---|---| | Authorization Code | ✅ | grant principal | | PKCE (S256, plain) | ✅ | obligatoire pour les clients publics (sans secret) | | Refresh token | ✅ | si le client a le grant refresh_token | | Document de découverte /.well-known/openid-configuration | ✅ | | | Endpoint UserInfo | ✅ | /api/oauth/userinfo | | id_token (JWT OIDC) | ❌ | non émis : utilisez UserInfo pour l'identité | | jwks_uri | ❌ | non publié : traitez l'access_token comme opaque | | end_session_endpoint (logout OIDC) | ❌ | voir Déconnexion | | Révocation (RFC 7009) / introspection | ❌ | non exposés | | Implicit grant, password grant | ❌ | non activés | | Client credentials | ⚠️ | proposé dans l'admin, mais l'API utilisateur exige un utilisateur. Voir FAQ | | Device code | ⚠️ | routes Passport présentes (/oauth/device*) mais non proposées dans l'admin ni dans la découverte. Non supporté, à confirmer |

Dans l'interface d'administration, le module s'appelle « Switch Connect » (menu Pilotage → Applications connectées).

Base URLs et environnements

| Environnement | Base URL | Statut | |---|---|---| | Production | https://switchcompagnie.eu | vérifiée : l'issuer de la découverte prod vaut https://switchcompagnie.eu | | Local (dev de la plateforme) | http://localhost (APP_URL de .env.example) | | | Staging / préprod | - | à confirmer, aucun environnement de recette dans le code |

Toutes les URLs sont construites à partir de APP_URL (url('/') dans DiscoveryController).

| Rôle | URL (prod) | |---|---| | Découverte | GET https://switchcompagnie.eu/.well-known/openid-configuration | | Autorisation | GET https://switchcompagnie.eu/oauth/authorize | | Jeton | POST https://switchcompagnie.eu/oauth/token | | UserInfo | GET ou POST https://switchcompagnie.eu/api/oauth/userinfo | | API REST | https://switchcompagnie.eu/api/... | | Doc OpenAPI interactive (Scramble) | https://switchcompagnie.eu/docs/api · JSON : https://switchcompagnie.eu/docs/api.json · Swagger : https://switchcompagnie.eu/api-docs |

Réponse réelle de la découverte en production :

{
  "issuer": "https://switchcompagnie.eu",
  "authorization_endpoint": "https://switchcompagnie.eu/oauth/authorize",
  "token_endpoint": "https://switchcompagnie.eu/oauth/token",
  "userinfo_endpoint": "https://switchcompagnie.eu/api/oauth/userinfo",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "scopes_supported": ["openid","profile","email","phone","billing","billing:create","billing:pay"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic","client_secret_post","none"],
  "code_challenge_methods_supported": ["S256","plain"],
  "claims_supported": ["sub","name","preferred_username","locale","picture","updated_at","email","email_verified","phone_number","phone_number_verified"]
}

2. Créer un client OAuth

Il n'y a pas de création en libre-service : seul un administrateur Switch Compagnie (User::isAdmin()) crée les clients.

2.1 Via l'admin (méthode normale)

  1. Se connecter sur https://switchcompagnie.eu/admin avec un compte administrateur.
  2. Menu Pilotage → Switch Connect (/admin/o-auth-client/o-auth-clients), puis Créer.
  3. Remplir :
    • Nom du projet : affiché à l'utilisateur sur l'écran de consentement (« Nom souhaite se connecter avec votre compte Switch Compagnie »).
    • Redirect URIs : une ou plusieurs URLs de callback exactes, par exemple https://mon-projet.fr/auth/switchcompagnie/callback. Ajoutez aussi l'URL locale si besoin (http://localhost:8000/auth/switchcompagnie/callback).
    • Grant types : authorization_code + refresh_token par défaut. Gardez ces deux-là.
    • Client confidentiel (génère un secret) : coché par défaut.
      • Coché, pour un backend serveur comme une app Laravel : un secret de 40 caractères est généré et affiché une seule fois. Il est stocké haché en base et ne pourra plus être relu.
      • Décoché, pour une SPA ou une app mobile : client public sans secret, PKCE obligatoire.
  4. Récupérer le Client ID (UUID, bouton « Copier l'ID ») et le secret.

Actions disponibles ensuite : Régénérer le secret (l'ancien est invalidé immédiatement et le nouveau est affiché une seule fois) et Révoqué (désactive le client).

2.2 Via Artisan (sur le serveur de la plateforme)

Commande standard de Passport 13 :

php artisan passport:client
# questions interactives : nom, redirect URI(s), confidentiel ?

L'admin reste préférable, parce qu'il fixe proprement les grant_types et affiche le secret une fois. Les options exactes de la commande dépendent de Passport 13 (php artisan passport:client --help).

ℹ️ À ne pas confondre : la page client « Clés API » (/user/api-keys) sert à demander une clé API Sanctum personnelle, validée par un admin et valable API_KEY_EXPIRES_AFTER_DAYS jours (365 par défaut). Ce n'est pas Switch Connect : un tel jeton n'est pas restreint par les scopes et n'agit que pour le compte qui l'a demandé.


3. Flux OAuth 2.0 Authorization Code (+ PKCE)

3.1 Scopes disponibles

Liste exacte, déclarée dans Passport::tokensCan() (app/Providers/AppServiceProvider.php) :

| Scope | Libellé affiché à l'utilisateur | Effet | |---|---|---| | openid | Vérifier votre identité Switch Compagnie | requis pour /api/oauth/userinfo. Scope par défaut si aucun n'est demandé | | profile | Accéder à votre profil (nom, avatar, langue) | claims name, preferred_username, locale, picture, updated_at | | email | Accéder à votre adresse e-mail | claims email, email_verified | | phone | Accéder à votre numéro de téléphone | claims phone_number, phone_number_verified | | billing | Consulter vos factures, abonnements et portefeuille | GET /api/invoices*, /api/subscriptions*, /api/user/wallet | | billing:create | Créer des factures liées à vos commandes | POST /api/invoices | | billing:pay | Payer vos factures avec votre portefeuille | POST /api/invoices/{id}/pay |

Les scopes sont séparés par des espaces (scope=openid profile email). Demandez le minimum nécessaire.

Durées de vie (même fichier) :

  • access token : 1 heure (expires_in: 3600) ;
  • refresh token : 30 jours.

3.2 Étape 1 : rediriger l'utilisateur vers /oauth/authorize

GET https://switchcompagnie.eu/oauth/authorize
    ?response_type=code
    &client_id=<CLIENT_ID>
    &redirect_uri=<REDIRECT_URI exactement comme enregistrée>
    &scope=openid%20profile%20email
    &state=<aléatoire, ≥ 32 caractères>
    &code_challenge=<BASE64URL(SHA256(code_verifier))>
    &code_challenge_method=S256
    [&prompt=none|login|consent]

| Paramètre | Obligatoire | Détail | |---|---|---| | response_type | oui | toujours code | | client_id | oui | UUID du client | | redirect_uri | oui | doit correspondre à l'une des Redirect URIs du client | | scope | conseillé | défaut : openid | | state | fortement conseillé | anti-CSRF, à revérifier au retour | | code_challenge + code_challenge_method | oui pour un client public, conseillé sinon | S256 (recommandé) ou plain | | prompt | non | géré par Passport 13 : none (pas d'écran, erreur login_required / consent_required si impossible), login (force une nouvelle connexion), consent (force l'écran de consentement) |

Ce qui se passe côté Switch Compagnie :

  1. L'utilisateur non connecté est envoyé vers /login (2FA si elle est activée sur son compte), puis revient.
  2. L'écran « Autorisation OAuth » affiche le nom du client, le compte connecté et les libellés des scopes, avec les boutons Autoriser et Refuser.
  3. Si l'utilisateur a déjà accordé ces scopes à ce client et qu'un jeton non révoqué et non expiré existe, l'écran est sauté (comportement Passport hasGrantedScopes).

Retour :

  • accepté : 302 <redirect_uri>?code=<CODE>&state=<STATE>
  • refusé : 302 <redirect_uri>?error=access_denied&error_description=...&state=<STATE>

Le code est à usage unique et de courte durée : 10 minutes par défaut dans Passport (à confirmer, rien n'est surchargé dans le code).

3.3 Étape 2 : échanger le code contre des jetons, POST /oauth/token

Client confidentiel (secret dans le corps, client_secret_post) :

curl -X POST https://switchcompagnie.eu/oauth/token \
  -H "Accept: application/json" \
  -d grant_type=authorization_code \
  -d client_id="$SWITCH_CONNECT_CLIENT_ID" \
  -d client_secret="$SWITCH_CONNECT_CLIENT_SECRET" \
  -d redirect_uri="https://mon-projet.fr/auth/switchcompagnie/callback" \
  -d code="$CODE" \
  -d code_verifier="$CODE_VERIFIER"     # si PKCE utilisé à l'étape 1

Variante HTTP Basic (client_secret_basic) :

curl -X POST https://switchcompagnie.eu/oauth/token \
  -u "$SWITCH_CONNECT_CLIENT_ID:$SWITCH_CONNECT_CLIENT_SECRET" \
  -H "Accept: application/json" \
  -d grant_type=authorization_code -d redirect_uri=... -d code=... -d code_verifier=...

Client public (none) : pas de client_secret, code_verifier obligatoire.

Réponse (format Passport / league/oauth2-server) :

{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "def50200..."
}

L'access_token est techniquement un JWT, mais aucune clé publique (JWKS) n'est publiée : ne le décodez pas pour en tirer des informations, utilisez /api/oauth/userinfo.

3.4 Étape 3 : rafraîchir le jeton

curl -X POST https://switchcompagnie.eu/oauth/token \
  -H "Accept: application/json" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN" \
  -d client_id="$SWITCH_CONNECT_CLIENT_ID" \
  -d client_secret="$SWITCH_CONNECT_CLIENT_SECRET" \
  -d scope="openid profile email"   # facultatif : sous-ensemble des scopes initiaux

La réponse a le même format. Un nouveau refresh token est émis : remplacez l'ancien, qui est révoqué (rotation par défaut de Passport). Le client doit avoir le grant refresh_token.

⚠️ POST /oauth/token/refresh existe aussi, mais c'est la route Passport des « transient tokens » (cookie de session). Ne l'utilisez pas pour une intégration tierce.

3.5 Erreurs OAuth

Format standard OAuth 2.0 (exemple réel en prod avec un client inexistant) :

HTTP/1.1 401
{"error":"invalid_client","error_description":"Client authentication failed"}

| error | Cause typique | |---|---| | invalid_client (401) | client_id inconnu ou révoqué, mauvais secret, secret régénéré | | invalid_grant (400) | code expiré ou déjà utilisé, redirect_uri différente de l'étape 1, code_verifier incorrect, refresh token expiré ou révoqué | | invalid_request (400) | paramètre manquant (par ex. code_challenge absent pour un client public) | | invalid_scope (400) | scope inexistant (hors de la liste §3.1) | | unauthorized_client | grant non autorisé pour ce client (par ex. refresh_token absent des grant types) | | access_denied | l'utilisateur a cliqué « Refuser » | | login_required / consent_required | avec prompt=none |


4. Endpoints API

En-têtes à envoyer sur toutes les requêtes API :

Authorization: Bearer <access_token>
Accept: application/json

Sans Accept: application/json, une requête non authentifiée peut être redirigée vers /login (comportement Laravel) au lieu de renvoyer un 401 JSON.

4.1 GET|POST /api/oauth/userinfo : identité OIDC

  • Middleware : auth:api (Passport), CheckToken::using('openid') ; scope openid requis.
  • Pas de limitation de débit applicative.
curl https://switchcompagnie.eu/api/oauth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "Accept: application/json"

Réponse avec openid profile email phone (les claims n'apparaissent que si le scope correspondant est présent) :

{
  "sub": "42",
  "name": "Jeanne Martin",
  "preferred_username": "Jeanne Martin",
  "locale": "fr",
  "picture": "https://switchcompagnie.eu/storage/avatars/xxxx.jpg",
  "updated_at": 1760190000,
  "email": "jeanne@example.com",
  "email_verified": true,
  "phone_number": "+33600000000",
  "phone_number_verified": false
}

| Claim | Scope | Source | |---|---|---| | sub | openid | ID utilisateur, chaîne. C'est l'identifiant stable à stocker | | name, preferred_username | profile | tous deux = users.name (pas de pseudo distinct) | | locale | profile | users.locale (peut être null) | | picture | profile | URL publique de l'avatar ou null | | updated_at | profile | timestamp Unix | | email, email_verified | email | | | phone_number | phone | peut être null | | phone_number_verified | phone | toujours false (pas de vérification de téléphone) |

Erreurs : 401 {"message":"Unauthenticated."} (jeton absent, expiré ou révoqué) ; 403 si le jeton n'a pas openid.

4.2 API REST utilisable avec un jeton Switch Connect

Le groupe principal de routes/api.php accepte Sanctum ou Passport (auth:sanctum,api), avec throttle:api.

| Méthode | Chemin | Scope Switch Connect requis | |---|---|---| | GET | /api/user | aucun scope spécifique (voir ⚠️ §6.5) | | PATCH | /api/user | aucun | | GET | /api/user/notifications | aucun | | POST | /api/user/notifications/read | aucun | | DELETE | /api/user/notifications · /api/user/notifications/{id} | aucun | | GET | /api/user/login-history | aucun | | GET | /api/user/wallet | billing | | GET | /api/invoices · /api/invoices/{id} | billing | | GET | /api/subscriptions · /api/subscriptions/{id} | billing | | POST | /api/invoices | billing:create | | POST | /api/invoices/{id}/pay | billing:pay | | GET/POST/DELETE/PATCH | /api/appointments…, /api/contracts…, /api/tickets… | aucun (voir la doc Scramble pour les corps) | | POST | /api/auth/logout | aucun : supprime le jeton courant |

Endpoints publics (sans jeton) : GET /api/news, /api/news/{slug}, /api/services, /api/services/{id}, /api/projects, /api/projects/{id}, /api/search (30 req/min).

Le détail complet des schémas est généré automatiquement sur /docs/api (Scramble, toutes les routes api/*).

Les endpoints /api/auth/login et /api/auth/register émettent des jetons Sanctum (application interne) et ne font pas partie de Switch Connect. Les routes /api/discord/* sont réservées au bot Discord.

GET /api/user

curl https://switchcompagnie.eu/api/user -H "Authorization: Bearer $ACCESS_TOKEN" -H "Accept: application/json"
{
  "data": {
    "id": 42,
    "name": "Jeanne Martin",
    "email": "jeanne@example.com",
    "phone": "+33600000000",
    "role": "membre",
    "referral_code": "ABCD1234",
    "created_at": "2025-03-01T10:00:00.000000Z"
  }
}

PATCH /api/user

Champs facultatifs : name (≤ 255), phone (≤ 20, nullable), password + password_confirmation + current_password (8 caractères minimum, majuscules et minuscules, chiffres). Réponse : {"id","name","email","phone"}. Erreur de validation : 422.

GET /api/user/wallet (scope billing)

{
  "balance": 25.5,
  "currency": "EUR",
  "transactions": [
    {"amount": -10.0, "type": "debit", "description": "Paiement facture …", "date": "2026-10-01T12:00:00.000000Z"}
  ]
}

Les 50 dernières transactions. Les valeurs de type sont à confirmer (modèle WalletTransaction).

GET /api/invoices (scope billing)

Paginé (15 par page, ?page=2), trié par échéance décroissante. Format Laravel { "data": [...], "links": {...}, "meta": {...} }, chaque élément :

{
  "id": 7,
  "invoice_number": "FAC-2026-0007",
  "amount": 15.64,
  "description": "Impression 3D PrintoLab - cube.stl (PLA)",
  "status": "pending",
  "due_date": "2026-10-25",
  "paid_at": null,
  "created_at": "2026-10-11T14:00:00.000000Z"
}

GET /api/invoices/{id} renvoie 403 si la facture n'appartient pas à l'utilisateur. Le format exact de invoice_number est à confirmer.

POST /api/invoices (scope billing:create)

Pour un partenaire qui facture une commande à l'utilisateur. Échéance fixée à J+14.

curl -X POST https://switchcompagnie.eu/api/invoices \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"amount": 15.64, "description": "Impression 3D PrintoLab - cube.stl (PLA)"}'
  • amount : obligatoire, numérique, de 0,50 à 10 000, arrondi à 2 décimales ;
  • description : facultative, 255 caractères maximum.

Réponse 201 : {"data": { ...facture, "status": "pending" } }. Sinon 422 (validation) ou 403 (scope manquant).

POST /api/invoices/{id}/pay (scope billing:pay)

Débite le portefeuille. Réponse :

{"message":"Facture payée avec succès.","invoice_number":"FAC-…","paid_at":"2026-10-11T14:05:00.000000Z","wallet_balance":9.86}

Erreurs : 403 si la facture n'est pas à l'utilisateur ou si le scope manque. Les codes et messages en cas de solde insuffisant ou de facture déjà payée sont à confirmer (action App\Actions\PayInvoiceWithWallet).

4.3 Erreurs API

| Code | Corps | Cas | |---|---|---| | 401 | {"message":"Unauthenticated."} | jeton absent, invalide, expiré ou révoqué. Faites un refresh | | 403 | {"message":"Portée OAuth insuffisante (billing requise)."} | scope billing* manquant (EnsureTokenCan) | | 403 | {"message":"..."} | ressource d'un autre utilisateur | | 404 | | ressource inexistante | | 422 | {"message":"...","errors":{"champ":["..."]}} | validation | | 429 | {"message":"Too Many Attempts."} + Retry-After | limite de débit |

4.4 Limites de débit

| Périmètre | Limite | Source | |---|---|---| | Groupe API authentifié (throttle:api) | 60 requêtes/minute par utilisateur | RateLimiter::for('api') | | /api/search | 30/min par IP | routes/api.php | | /api/contracts/{id}/otp | 3 / 5 min | | | /api/contracts/{id}/sign | 5/min | | | /api/oauth/userinfo | aucune limite applicative | | | /oauth/authorize, /oauth/token | aucune limite applicative dans le code | limite éventuelle côté nginx/Plesk à confirmer |

Les réponses limitées portent les en-têtes X-RateLimit-Limit et X-RateLimit-Remaining.


5. Intégration Laravel pas à pas

Exemple : une application Laravel 11/12/13 (« l'app cliente ») qui propose « Se connecter avec Switch Compagnie », stocke les jetons et appelle l'API.

5.1 Prérequis

composer require laravel/socialite

Côté Switch Compagnie, un client confidentiel avec la Redirect URI https://mon-app.fr/auth/switchcompagnie/callback (§2).

5.2 .env

SWITCH_CONNECT_BASE_URL=https://switchcompagnie.eu
SWITCH_CONNECT_CLIENT_ID=
SWITCH_CONNECT_CLIENT_SECRET=
SWITCH_CONNECT_REDIRECT_URI="${APP_URL}/auth/switchcompagnie/callback"
SWITCH_CONNECT_SCOPES="openid profile email"
# Alias temporaire (déprécié) : SWITCH_CONNECT_URL= (même valeur si BASE_URL vide)

5.3 config/services.php

'switchconnect' => [
    'base_url'      => env('SWITCH_CONNECT_BASE_URL') ?: env('SWITCH_CONNECT_URL', 'https://switchcompagnie.eu'),
    'client_id'     => env('SWITCH_CONNECT_CLIENT_ID'),
    'client_secret' => env('SWITCH_CONNECT_CLIENT_SECRET'),
    'redirect'      => env('SWITCH_CONNECT_REDIRECT_URI') ?: env('SWITCH_CONNECT_REDIRECT'),
    'scopes'        => explode(' ', env('SWITCH_CONNECT_SCOPES', 'openid profile email')),
],

5.4 Provider Socialite : app/Socialite/SwitchConnectProvider.php

Il s'écrit dans le style de SocialiteProviders, mais sans dépendance supplémentaire.

<?php

namespace App\Socialite;

use GuzzleHttp\RequestOptions;
use Illuminate\Support\Arr;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;

class SwitchConnectProvider extends AbstractProvider implements ProviderInterface
{
    /** Scopes séparés par des espaces (OAuth 2.0). */
    protected $scopeSeparator = ' ';

    /** Scopes demandés par défaut. */
    protected $scopes = ['openid', 'profile', 'email'];

    /** PKCE S256 : recommandé même pour un client confidentiel. */
    protected $usesPKCE = true;

    protected function baseUrl(): string
    {
        return rtrim(config('services.switchconnect.base_url', 'https://switchcompagnie.eu'), '/');
    }

    protected function getAuthUrl($state): string
    {
        return $this->buildAuthUrlFromBase($this->baseUrl().'/oauth/authorize', $state);
    }

    protected function getTokenUrl(): string
    {
        return $this->baseUrl().'/oauth/token';
    }

    /** Identité via l'endpoint UserInfo (pas d'id_token chez Switch Connect). */
    protected function getUserByToken($token): array
    {
        $response = $this->getHttpClient()->get($this->baseUrl().'/api/oauth/userinfo', [
            RequestOptions::HEADERS => [
                'Accept' => 'application/json',
                'Authorization' => 'Bearer '.$token,
            ],
        ]);

        return json_decode((string) $response->getBody(), true);
    }

    protected function mapUserToObject(array $user): User
    {
        return (new User)->setRaw($user)->map([
            'id'       => Arr::get($user, 'sub'),
            'nickname' => Arr::get($user, 'preferred_username'),
            'name'     => Arr::get($user, 'name'),
            'email'    => Arr::get($user, 'email'),
            'avatar'   => Arr::get($user, 'picture'),
        ]);
    }
}

Socialite envoie client_id et client_secret dans le corps du POST /oauth/token (client_secret_post, supporté), plus code_verifier grâce à $usesPKCE. Pour un client public, laissez client_secret vide : PKCE est alors indispensable.

5.5 Enregistrer le provider : app/Providers/AppServiceProvider.php

use App\Socialite\SwitchConnectProvider;
use Laravel\Socialite\Facades\Socialite;

public function boot(): void
{
    Socialite::extend('switchconnect', function ($app) {
        $config = $app['config']['services.switchconnect'];

        return Socialite::buildProvider(SwitchConnectProvider::class, $config)
            ->scopes($config['scopes'] ?? ['openid', 'profile', 'email']);
    });
}

5.6 Migration : lier les comptes et stocker les jetons

// database/migrations/xxxx_add_switch_connect_to_users_table.php
Schema::table('users', function (Blueprint $table) {
    $table->string('switch_connect_id')->nullable()->unique();   // claim "sub"
    $table->text('switch_connect_token')->nullable();             // chiffré (cast)
    $table->text('switch_connect_refresh_token')->nullable();     // chiffré (cast)
    $table->timestamp('switch_connect_expires_at')->nullable();
    $table->string('password')->nullable()->change();             // si comptes 100 % Switch Connect
});

app/Models/User.php :

protected $hidden = ['password', 'remember_token', 'switch_connect_token', 'switch_connect_refresh_token'];

protected function casts(): array
{
    return [
        'switch_connect_token'         => 'encrypted',
        'switch_connect_refresh_token' => 'encrypted',
        'switch_connect_expires_at'    => 'datetime',
    ];
}

5.7 Routes et contrôleur

routes/web.php :

use App\Http\Controllers\Auth\SwitchConnectController;

Route::get('/auth/switchcompagnie/redirect', [SwitchConnectController::class, 'redirect'])->name('switchconnect.redirect');
Route::get('/auth/switchcompagnie/callback', [SwitchConnectController::class, 'callback'])->name('switchconnect.callback');
Route::post('/logout', [SwitchConnectController::class, 'logout'])->middleware('auth')->name('logout');

app/Http/Controllers/Auth/SwitchConnectController.php :

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;
use Throwable;

class SwitchConnectController extends Controller
{
    public function redirect(): RedirectResponse
    {
        // state + code_challenge générés et stockés en session par Socialite
        return Socialite::driver('switchconnect')->redirect();
    }

    public function callback(Request $request): RedirectResponse
    {
        if ($request->filled('error')) {               // ex. access_denied
            return redirect()->route('login')->withErrors([
                'switchconnect' => __('Connexion Switch Compagnie annulée.'),
            ]);
        }

        try {
            $remote = Socialite::driver('switchconnect')->user();   // vérifie state + échange le code
        } catch (InvalidStateException) {
            return redirect()->route('login')->withErrors(['switchconnect' => __('Session expirée, réessayez.')]);
        } catch (Throwable $e) {
            report($e);
            return redirect()->route('login')->withErrors(['switchconnect' => __('Connexion impossible.')]);
        }

        // 1) Compte déjà lié (clé stable = sub)
        $user = User::where('switch_connect_id', $remote->getId())->first();

        // 2) Sinon, rattacher un compte local existant par e-mail VÉRIFIÉ uniquement
        if (! $user && $remote->getEmail() && ($remote->getRaw()['email_verified'] ?? false)) {
            $user = User::where('email', $remote->getEmail())->whereNull('switch_connect_id')->first();
        }

        // 3) Sinon, créer le compte
        $user ??= new User(['email' => $remote->getEmail()]);

        $user->forceFill([
            'name'                         => $remote->getName() ?? $user->name ?? 'Utilisateur',
            'switch_connect_id'            => $remote->getId(),
            'switch_connect_token'         => $remote->token,
            'switch_connect_refresh_token' => $remote->refreshToken,
            'switch_connect_expires_at'    => now()->addSeconds($remote->expiresIn ?? 3600),
        ])->save();

        Auth::login($user, remember: true);
        $request->session()->regenerate();

        return redirect()->intended('/dashboard');
    }

    public function logout(Request $request): RedirectResponse
    {
        app(\App\Services\SwitchConnectClient::class)->revokeCurrentToken($request->user());

        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect('/');
    }
}

Si l'e-mail n'est pas demandé (pas de scope email), getEmail() vaut null : rendez la colonne email nullable ou demandez toujours email.

5.8 Client API avec rafraîchissement automatique : app/Services/SwitchConnectClient.php

<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class SwitchConnectClient
{
    private function base(): string
    {
        return rtrim(config('services.switchconnect.base_url'), '/');
    }

    /** Retourne un access token valide, rafraîchi si besoin (marge 60 s). */
    public function accessToken(User $user): string
    {
        if ($user->switch_connect_expires_at?->isAfter(now()->addSeconds(60))) {
            return $user->switch_connect_token;
        }

        // Verrou : évite deux refresh concurrents (le refresh token est à usage unique).
        return Cache::lock('switchconnect-refresh-'.$user->id, 10)->block(5, function () use ($user) {
            $user->refresh();
            if ($user->switch_connect_expires_at?->isAfter(now()->addSeconds(60))) {
                return $user->switch_connect_token;
            }

            $response = Http::asForm()->acceptJson()->post($this->base().'/oauth/token', [
                'grant_type'    => 'refresh_token',
                'refresh_token' => $user->switch_connect_refresh_token,
                'client_id'     => config('services.switchconnect.client_id'),
                'client_secret' => config('services.switchconnect.client_secret'),
            ]);

            if ($response->failed()) {
                // invalid_grant : refresh expiré (30 j) ou révoqué -> reconnexion obligatoire
                $user->forceFill(['switch_connect_token' => null, 'switch_connect_refresh_token' => null, 'switch_connect_expires_at' => null])->save();
                throw new RuntimeException('Switch Connect : reconnexion requise ('.$response->json('error').')');
            }

            $user->forceFill([
                'switch_connect_token'         => $response->json('access_token'),
                'switch_connect_refresh_token' => $response->json('refresh_token'),
                'switch_connect_expires_at'    => now()->addSeconds($response->json('expires_in', 3600)),
            ])->save();

            return $user->switch_connect_token;
        });
    }

    public function for(User $user): PendingRequest
    {
        return Http::baseUrl($this->base().'/api')
            ->acceptJson()
            ->withToken($this->accessToken($user))
            ->retry(2, 500, fn ($e) => $e->response?->status() === 429, throw: false);
    }

    // Exemples d'appels
    public function userinfo(User $user): array { return $this->for($user)->get('/oauth/userinfo')->throw()->json(); }
    public function invoices(User $user, int $page = 1): array { return $this->for($user)->get('/invoices', ['page' => $page])->throw()->json(); }   // scope billing
    public function createInvoice(User $user, float $amount, ?string $description = null): array                                                 // scope billing:create
    {
        return $this->for($user)->post('/invoices', compact('amount', 'description'))->throw()->json('data');
    }

    /** Déconnexion : supprime le jeton courant côté Switch Compagnie (POST /api/auth/logout). */
    public function revokeCurrentToken(?User $user): void
    {
        if (! $user?->switch_connect_token) {
            return;
        }

        Http::acceptJson()->withToken($user->switch_connect_token)
            ->post($this->base().'/api/auth/logout');   // erreur ignorée volontairement

        $user->forceFill(['switch_connect_token' => null, 'switch_connect_refresh_token' => null, 'switch_connect_expires_at' => null])->save();
    }
}

Socialite propose aussi Socialite::driver('switchconnect')->refreshToken($refresh) depuis la 5.x. Le service ci-dessus reste explicite et gère le verrou.

5.9 Bouton de connexion (Blade)

<a href="{{ route('switchconnect.redirect') }}" class="btn">
    Se connecter avec Switch Compagnie
</a>

5.10 Déconnexion

Switch Connect n'a pas d'endpoint de logout OIDC (end_session_endpoint). La déconnexion de votre app :

  1. supprime le jeton courant via POST /api/auth/logout. Ce endpoint fait currentAccessToken()->delete(), ce qui supprime aussi la ligne Passport ;
  2. efface les jetons stockés localement ;
  3. ferme la session Laravel locale.

La session web Switch Compagnie de l'utilisateur reste ouverte. Pour forcer une nouvelle connexion au prochain login, passez prompt=login : Socialite::driver('switchconnect')->with(['prompt' => 'login'])->redirect().

⚠️ Après la suppression de l'access token, l'invalidation du refresh token associé n'est pas garantie par le code. À confirmer : c'est pourquoi le service l'efface aussi localement.

L'utilisateur ou un admin peut voir les applications connectées dans la fiche utilisateur de l'admin (« Applications tierces connectées avec ce compte »). La page de révocation en libre-service côté utilisateur est à confirmer (aucune route trouvée).

5.11 Tester en local

  • Ajoutez http://localhost:8000/auth/switchcompagnie/callback aux Redirect URIs du client (ou un client dédié au dev).
  • Pour tester contre une instance locale de la plateforme : SWITCH_CONNECT_BASE_URL=http://localhost. Les clés Passport doivent exister (php artisan passport:keys).
  • Dans les tests de l'app cliente : Http::fake([...'/oauth/token' => ..., '/api/oauth/userinfo' => ...]) et Socialite::fake() (Socialite ≥ 5.x, à confirmer selon votre version).

6. Sécurité

  1. state : toujours aléatoire et revérifié au retour. Socialite le fait si vous n'utilisez pas stateless(). N'appelez stateless() que pour une API sans session, et ajoutez alors PKCE et votre propre protection.
  2. PKCE S256 : obligatoire pour les clients publics, recommandé pour tous. Évitez plain.
  3. Secret client : uniquement côté serveur (.env, coffre de secrets), jamais dans un front, une app mobile ou un dépôt git. Affiché une seule fois : s'il est perdu ou fuité, faites Régénérer le secret dans l'admin (l'ancien est invalidé immédiatement).
  4. Stockage des jetons : en base chiffrés (encrypted cast), jamais dans localStorage ni dans une URL. Rotation : remplacez toujours le refresh token par celui renvoyé.
  5. Scopes minimaux : billing:create et billing:pay permettent de créer et payer des factures avec le portefeuille de l'utilisateur. Ne les demandez que si l'app en a réellement besoin, et confirmez chaque paiement côté UI.
  6. ⚠️ Portée réelle des jetons : dans le code actuel, un jeton Switch Connect, même avec seulement openid, accède aussi aux routes du groupe auth:sanctum,api non soumises à un scope : /api/user (e-mail et téléphone compris), PATCH /api/user, notifications, historique de connexion, rendez-vous, contrats, tickets. Seules les routes billing* vérifient le scope. Côté intégrateur, n'appelez que ce dont vous avez besoin. Côté plateforme, c'est un point à durcir (voir les points ouverts).
  7. HTTPS obligatoire en production pour la Redirect URI et tous les appels. Les Redirect URIs doivent être exactes : pas de wildcard, pas d'open redirect.
  8. Identifiant stable : liez les comptes sur sub, jamais sur l'e-mail seul. Le rattachement par e-mail n'est acceptable que si email_verified === true.
  9. Durée de vie : access token 1 h, refresh token 30 jours. Après 30 jours d'inactivité, l'utilisateur doit se reconnecter.
  10. Comptes désactivés : la plateforme supprime ou révoque les jetons des comptes inactifs (DeactivateInactiveAccounts), ainsi qu'au changement de mot de passe ou de 2FA. Gérez proprement un 401 ou un invalid_grant en relançant la connexion.

7. FAQ / dépannage

invalid_client au /oauth/token Mauvais client_id ou secret, secret régénéré, client révoqué, ou secret envoyé à la fois en Basic et dans le corps. Vérifiez que le secret est le texte en clair affiché à la création, pas une valeur hachée.

invalid_grant Le code a déjà été utilisé (double appel au callback, rechargement de page), il a expiré, la redirect_uri diffère au caractère près (slash final, http/https, port) ou le code_verifier est incorrect.

invalid_request : « code_challenge » requis Le client est public (sans secret) : PKCE est obligatoire ($usesPKCE = true).

InvalidStateException (Socialite) La session a été perdue entre la redirection et le callback : domaine de cookie, SESSION_DOMAIN, SameSite=strict (préférez lax), callback sur un autre sous-domaine, ou utilisation de stateless() d'un seul côté.

/api/oauth/userinfo renvoie 401 Le jeton est expiré (1 h) : faites un refresh. Ajoutez aussi Accept: application/json, sinon vous pouvez obtenir une redirection 302 vers /login.

/api/oauth/userinfo renvoie 403 Le jeton n'a pas le scope openid. Ajoutez-le explicitement au scope.

Le claim email (ou name) est absent Le scope email (ou profile) n'a pas été demandé ou accordé.

403 Portée OAuth insuffisante (billing requise). Demandez le scope billing (ou billing:create / billing:pay). L'utilisateur doit ré-autoriser l'app : relancez le flux avec les nouveaux scopes.

L'écran de consentement ne s'affiche plus C'est normal : l'utilisateur a déjà accordé ces scopes et un jeton actif existe. Forcez-le avec prompt=consent.

429 Too Many Attempts 60 requêtes/min par utilisateur sur l'API authentifiée. Respectez Retry-After et mettez en cache userinfo.

Puis-je utiliser client_credentials (machine à machine) ? Le grant est proposé dans l'admin, mais toutes les routes API utiles exigent un utilisateur ($request->user()). Un jeton client credentials n'y a donc pas accès. Usage non documenté et à confirmer avec l'équipe plateforme.

Y a-t-il un id_token ? Non. Les bibliothèques OIDC strictes qui exigent un id_token ou un JWKS ne fonctionneront pas telles quelles : utilisez l'approche OAuth 2.0 + UserInfo ci-dessus.

Où sont les schémas complets ? Sur https://switchcompagnie.eu/docs/api (OpenAPI généré par Scramble à partir du code).


8. Annexe : sources dans le code

Toutes relatives à la racine du dépôt SwitchCompagnie-Main-Web (main @ fc34a71) :

  • app/Providers/AppServiceProvider.php : Passport::tokensCan (scopes), defaultScopes(['openid']), durées 1 h / 30 j, vue oauth.authorize, rate limiter api (60/min), journalisation des événements de jetons
  • app/Http/Controllers/Oauth/DiscoveryController.php : /.well-known/openid-configuration
  • app/Http/Controllers/Oauth/UserInfoController.php : claims UserInfo
  • routes/api.php : /api/oauth/userinfo, groupe auth:sanctum,api, token.can:billing*, throttles
  • routes/web.php : découverte, /api-docs, /login, /user/api-keys
  • app/Http/Middleware/EnsureTokenCan.php : erreur 403 de scope
  • bootstrap/app.php : alias token.can
  • app/Admin/Resources/OAuthClientResource.php : création des clients (admin « Switch Connect »), secret affiché une fois, régénération
  • resources/views/oauth/authorize.blade.php : écran de consentement
  • resources/views/admin/fragments/switch-connect-apps.blade.php, app/Admin/Resources/UserResource.php, app/Http/Controllers/Admin/DashboardController.php : applications connectées et statistiques
  • app/Http/Controllers/Api/UserController.php, app/Http/Resources/UserResource.php
  • app/Http/Controllers/Api/InvoiceApiController.php, app/Http/Resources/InvoiceResource.php
  • app/Http/Controllers/Api/AuthController.php : /api/auth/logout
  • app/Admin/Resources/ApiKeyRequestResource.php, config/api_keys.php : clés API Sanctum (hors Switch Connect)
  • config/auth.php : guard api = passport
  • config/scramble.php : doc OpenAPI
  • tests/Feature/PassportApiAccessTest.php, tests/Feature/OAuthClientSecretTest.php
  • vendor/laravel/passport v13.9.0 : routes /oauth/*, gestion de prompt, PKCE, rotation des refresh tokens