mpociot/socialite-slack
Slack OAuth provider for Laravel Socialite. Adds a Slack driver so you can authenticate users with their Slack workspace account through Socialite's familiar redirect and callback API, alongside the built-in Google, GitHub, and Facebook providers.
mpociot/socialite-slack is a single-class Socialite OAuth2 driver for Slack. Day-to-day, you use it exactly like any other Socialite driver: Socialite::driver('slack')->redirect() out, Socialite::driver('slack')->user() back. Treat it as ~150 lines of OAuth glue you own, not a dependency you composer require and forget.
The last release (2017) targets Socialite 3.0; on Laravel 9+ / Socialite 5.x Composer will likely refuse to resolve it. Read it once, then vendor it:
composer require mpociot/socialite-slack --dev # scratch branch only, to read the source
Copy src/SlackProvider.php into app/Socialite/SlackProvider.php, namespace it App\Socialite, and remove the Composer entry. MIT license makes this clean. This is the whole "adoption" decision.
// config/services.php
'slack' => [
'client_id' => env('SLACK_CLIENT_ID'),
'client_secret' => env('SLACK_CLIENT_SECRET'),
'redirect' => env('SLACK_REDIRECT_URI'), // must match the Slack app exactly
],
// app/Providers/AppServiceProvider.php (boot)
use Laravel\Socialite\Facades\Socialite;
use App\Socialite\SlackProvider;
Socialite::extend('slack', function ($app) {
$config = $app['config']['services.slack'];
return Socialite::buildProvider(SlackProvider::class, $config);
});
// routes/web.php
Route::get('/auth/slack/redirect', [SlackAuthController::class, 'redirect'])->name('slack.redirect');
Route::get('/auth/slack/callback', [SlackAuthController::class, 'callback'])->name('slack.callback');
public function redirect()
{
return Socialite::driver('slack')
->scopes(['identity.basic', 'identity.email', 'identity.avatar', 'identity.team'])
->redirect();
}
public function callback()
{
$slackUser = Socialite::driver('slack')->user();
// $slackUser->getId(), ->getName(), ->getEmail(), ->getAvatar(), ->getRaw()
// Team identity lives in $slackUser->getRaw()['team']
}
Before you write a line of controller code, create the Slack app and set the redirect URL and user scopes in the dashboard. The most common first-run failure is a scope or redirect URI that doesn't match what the driver sends — Slack answers with invalid_scope or redirect_uri_mismatch, not a helpful stack trace.
Start with account linking, not login: an authenticated user clicks "Connect Slack," you store the token and (team_id, user_id) on their record. It's reversible, it doesn't change your login path, and it's the cheapest way to prove the flow works end-to-end before you make Slack a primary auth option.
Domain code should never touch the provider class. One interface, one implementation, one file to swap when you migrate to socialiteproviders/slack or Slack OIDC:
interface SlackIdentity
{
public function redirectUrl(array $scopes = []): string;
public function resolveCallback(): SlackAccount;
}
final class SlackAccount
{
public function __construct(
public string $teamId,
public string $userId,
public ?string $email,
public ?string $name,
public ?string $avatar,
public string $accessToken,
public array $raw,
) {}
}
final class SocialiteSlackIdentity implements SlackIdentity
{
public function resolveCallback(): SlackAccount
{
$user = Socialite::driver('slack')->user();
$raw = $user->getRaw();
return new SlackAccount(
teamId: $raw['team']['id'] ?? '',
userId: $raw['user']['id'] ?? $user->getId(),
email: $user->getEmail(),
name: $user->getName(),
avatar: $user->getAvatar(),
accessToken: $user->token,
raw: $raw,
);
}
}
Everything downstream (controllers, jobs, tests) depends on SlackIdentity, never on the package.
One human can be in five workspaces with the same email. Slack identity is the pair (team_id, user_id):
Schema::create('slack_identities', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('team_id');
$t->string('slack_user_id');
$t->string('access_token'); // cast to 'encrypted' on the model
$t->string('team_name')->nullable();
$t->string('slack_email')->nullable();
$t->timestamps();
$t->unique(['team_id', 'slack_user_id']);
$t->unique(['user_id', 'team_id']);
});
Cast access_token to encrypted on the Eloquent model. Never log the raw callback payload without redacting access_token.
stateless() for Anything Non-BrowserSocialite's default flow stores state in the session. For mobile handoffs, API-driven installs, or anything that starts outside the same browser session, use:
return Socialite::driver('slack')->stateless()->redirect();
…and ->stateless()->user() on the callback. If you stay stateful, you get CSRF protection for free — don't disable it in the browser flow "because it works."
The driver reads scopes from config, but per-flow overrides are clearer and more auditable:
// identity-only sign-in
Socialite::driver('slack')->scopes(['identity.basic', 'identity.email'])->redirect();
// install-flavoured (still user scopes — this package does NOT do bot scopes)
Socialite::driver('slack')->scopes(['identity.basic', 'identity.team', 'identity.avatar'])->redirect();
The scopes you send must be a subset of what's configured on the Slack app's User Token Scopes. Adding a scope in code without adding it in the dashboard is the single most common invalid_scope cause.
if (! config('features.slack_auth')) {
return redirect()->route('login')->with('status', 'Slack sign-in is not available.');
}
Slack-side failures surface on your callback route. A flag plus a fallback (redirect()->route('login')) keeps a broken Slack flow from becoming a broken login page.
The driver only makes two outbound requests: the token exchange and users.identity. Fake both:
Http::fake([
'slack.com/api/oauth.access' => Http::response(['ok' => true, 'access_token' => 'xoxp-test']),
'slack.com/api/users.identity' => Http::response([
'ok' => true,
'user' => ['id' => 'U123', 'name' => 'Ada', 'email' => '[email protected]', 'image_72' => '...'],
'team' => ['id' => 'T123', 'name' => 'Acme', 'domain' => 'acme'],
]),
]);
$this->get('/auth/slack/callback')->assertRedirect('/dashboard');
$this->assertDatabaseHas('slack_identities', ['team_id' => 'T123', 'slack_user_id' => 'U123']);
Add a contract test that asserts the driver's authorize and token URLs — it's the cheapest guard against Slack-side endpoint changes, and it fails loudly the day the legacy endpoints stop accepting traffic.
The endpoints are legacy. The driver targets slack.com/oauth/authorize and slack.com/api/oauth.access with the users.identity response shape. Slack has since moved to oauth/v2/authorize + oauth.v2.access, which returns authed_user rather than a top-level user, and to granular scopes. Check the current Slack OAuth docs before your spike, and expect to edit getAuthUrl(), getTokenUrl(), and getUserByToken() — this is a fork-and-fix, not a configure-and-go.
Sign in with Slack is now OIDC-flavoured. Newer Slack apps sign users in with openid / profile / email scopes and fetch identity from the OIDC userinfo endpoint. If your Slack app is configured that way, the legacy identity.* flow is not just deprecated — it's a different getUserByToken() implementation entirely. Decide which flow you're on before forking.
Slack returns HTTP 200 on failure. {"ok": false, "error": "invalid_scope"} comes back with a 200 status. The driver's getUserByToken() will happily json_decode that and hand you a broken user object. Add an explicit check in your fork:
$payload = json_decode($response->getBody(), true);
if (! ($payload['ok'] ?? false)) {
throw new InvalidStateException('Slack identity failed: '.($payload['error'] ?? 'unknown'));
}
Do this at every Slack call site, forever. It's Slack's most reliably repeated footgun.
getEmail() can be null. Slack users can hide their email, and if identity.email isn't granted you get null back — not an exception. Never use the Slack email as a required unique identifier or as a NOT NULL column. Map to an existing account by (team_id, slack_user_id) first, then by verified email if you must, and handle the null case as "ask the user to confirm."
Redirect URI is exact-match. http vs https, trailing slash, www, port — all must match the Slack app config character-for-character. Local dev via ngrok means updating the Slack app every time the tunnel URL changes; keep a .env var (SLACK_REDIRECT_URI) so you change it in one place.
Don't fight Composer. If the install fails on Socialite 5.x, that's the expected outcome, not a solvable problem. Copy the class, move on. If you do keep it in composer.json, composer why-not laravel/socialite will explain the block in one line — worth running before you spend an afternoon on constraints.
Extension points, if you keep it close to upstream:
getAuthUrl(), getTokenUrl(), getUserByToken(), and mapUserToObject() in a subclass — that's the whole surface.protected $scopeSeparator = ' ' — Slack uses space-separated scopes, unlike comma-separated providers. Copying a Google/Facebook provider and forgetting this breaks scopes silently.mapUserToObject() and read the rest from getRaw() — don't rely on undocumented User properties.Token lifecycle is entirely yours. The legacy flow gives you a user token with no refresh token and no rotation. You own: encryption at rest, expiry handling, revocation on uninstall (app_uninstalled webhook is not this package), and multi-workspace credential management. Budget for it explicitly — the OAuth handshake is the easy half.
Debugging checklist, in order:
ok: true, user.id, team.id?redirect_uri_mismatch → compare the string in config('services.slack.redirect') to the Slack dashboard, byte for byte.invalid_scope → dashboard scopes vs ->scopes([...]), and user-token vs bot-token scope sections.invalid_code → codes are single-use and expire in minutes; you're re-hitting the callback (double-submit, prefetch, retry middleware).user() → you called stateless() on redirect but not on callback, or vice versa.Where to extend next. Once the flow is green and flagged, the natural follow-ons are: a job that validates the token against auth.test, a team_id → tenant mapping so multi-workspace works, and only then the Web API (which is a different package or the official Slack SDK — don't try to grow this driver into it).
How can I help you explore Laravel packages today?