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/passportv13.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
- Présentation
- Créer un client OAuth
- Flux OAuth 2.0 Authorization Code (+ PKCE)
- Endpoints API
- Intégration Laravel pas à pas
- Sécurité
- FAQ / dépannage
- 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)
- Se connecter sur
https://switchcompagnie.eu/adminavec un compte administrateur. - Menu Pilotage → Switch Connect (
/admin/o-auth-client/o-auth-clients), puis Créer. - 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_tokenpar 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.
- 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 valableAPI_KEY_EXPIRES_AFTER_DAYSjours (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 :
- L'utilisateur non connecté est envoyé vers
/login(2FA si elle est activée sur son compte), puis revient. - L'écran « Autorisation OAuth » affiche le nom du client, le compte connecté et les libellés des scopes, avec les boutons Autoriser et Refuser.
- 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_tokenest 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/refreshexiste 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 un401JSON.
4.1 GET|POST /api/oauth/userinfo : identité OIDC
- Middleware :
auth:api(Passport),CheckToken::using('openid'); scopeopenidrequis. - 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
getEmail()vautnull: rendez la colonne
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 :
- supprime le jeton courant via
POST /api/auth/logout. Ce endpoint faitcurrentAccessToken()->delete(), ce qui supprime aussi la ligne Passport ; - efface les jetons stockés localement ;
- 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/callbackaux 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' => ...])etSocialite::fake()(Socialite ≥ 5.x, à confirmer selon votre version).
6. Sécurité
state: toujours aléatoire et revérifié au retour. Socialite le fait si vous n'utilisez passtateless(). N'appelezstateless()que pour une API sans session, et ajoutez alors PKCE et votre propre protection.- PKCE
S256: obligatoire pour les clients publics, recommandé pour tous. Évitezplain. - 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). - Stockage des jetons : en base chiffrés (
encryptedcast), jamais danslocalStorageni dans une URL. Rotation : remplacez toujours le refresh token par celui renvoyé. - Scopes minimaux :
billing:createetbilling:paypermettent 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. - ⚠️ 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 groupeauth:sanctum,apinon 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 routesbilling*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). - 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.
- Identifiant stable : liez les comptes sur
sub, jamais sur l'e-mail seul. Le rattachement par e-mail n'est acceptable que siemail_verified === true. - Durée de vie : access token 1 h, refresh token 30 jours. Après 30 jours d'inactivité, l'utilisateur doit se reconnecter.
- 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 un401ou uninvalid_granten 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, vueoauth.authorize, rate limiterapi(60/min), journalisation des événements de jetonsapp/Http/Controllers/Oauth/DiscoveryController.php:/.well-known/openid-configurationapp/Http/Controllers/Oauth/UserInfoController.php: claims UserInforoutes/api.php:/api/oauth/userinfo, groupeauth:sanctum,api,token.can:billing*, throttlesroutes/web.php: découverte,/api-docs,/login,/user/api-keysapp/Http/Middleware/EnsureTokenCan.php: erreur 403 de scopebootstrap/app.php: aliastoken.canapp/Admin/Resources/OAuthClientResource.php: création des clients (admin « Switch Connect »), secret affiché une fois, régénérationresources/views/oauth/authorize.blade.php: écran de consentementresources/views/admin/fragments/switch-connect-apps.blade.php,app/Admin/Resources/UserResource.php,app/Http/Controllers/Admin/DashboardController.php: applications connectées et statistiquesapp/Http/Controllers/Api/UserController.php,app/Http/Resources/UserResource.phpapp/Http/Controllers/Api/InvoiceApiController.php,app/Http/Resources/InvoiceResource.phpapp/Http/Controllers/Api/AuthController.php:/api/auth/logoutapp/Admin/Resources/ApiKeyRequestResource.php,config/api_keys.php: clés API Sanctum (hors Switch Connect)config/auth.php: guardapi=passportconfig/scramble.php: doc OpenAPItests/Feature/PassportApiAccessTest.php,tests/Feature/OAuthClientSecretTest.phpvendor/laravel/passportv13.9.0 : routes/oauth/*, gestion deprompt, PKCE, rotation des refresh tokens