socialiteproviders/jira
Jira OAuth provider for Laravel Socialite. Add Atlassian Jira login to your Laravel application with this Socialite driver, which handles the OAuth redirect and callback so users can authenticate through Jira with minimal setup. Part of the Socialite Providers collection.
socialiteproviders/jira adds an Atlassian Jira OAuth 2.0 (3LO) driver to Laravel Socialite. It gives you Socialite::driver('jira') — redirect, callback, and user retrieval — and nothing else. Everything past authentication (issues, projects, JQL) is plain Jira REST calls you make yourself with the returned token.
1. Install
composer require socialiteproviders/jira
2. Register the listener. This is the step people forget, and its symptom is Driver [jira] is not supported.
Laravel 11+ (AppServiceProvider::boot or a dedicated provider):
use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;
Event::listen(SocialiteWasCalled::class, function (SocialiteWasCalled $event) {
$event->extendSocialite('jira', \SocialiteProviders\Jira\Provider::class);
});
Laravel 10 and below (app/Providers/EventServiceProvider.php):
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
\SocialiteProviders\Jira\Provider::class . '@handle',
],
];
3. Create the Atlassian app. At developer.atlassian.com → your app → Authorization → OAuth 2.0 (3LO). Set the callback to the exact URL Laravel will hit, e.g. https://app.example.com/auth/jira/callback. Copy the client ID and secret. Add the Jira API scopes you actually need — classic scopes like read:jira-work, read:jira-user, and offline_access if you want refresh tokens.
4. Configure
// config/services.php
'jira' => [
'client_id' => env('JIRA_CLIENT_ID'),
'client_secret' => env('JIRA_CLIENT_SECRET'),
'redirect' => env('JIRA_REDIRECT_URI'),
],
5. First use case — log a user in and keep the token
Route::get('/auth/jira', fn () => Socialite::driver('jira')
->scopes(['read:jira-work', 'read:jira-user', 'offline_access'])
->redirect());
Route::get('/auth/jira/callback', function (Request $request) {
$jiraUser = Socialite::driver('jira')->user();
$user = User::updateOrCreate(
['jira_account_id' => $jiraUser->getId()],
['name' => $jiraUser->getName(), 'email' => $jiraUser->getEmail()],
);
$user->update([
'jira_access_token' => $jiraUser->token,
'jira_refresh_token' => $jiraUser->refreshToken,
'jira_expires_at' => now()->addSeconds($jiraUser->expiresIn ?? 3600),
]);
return redirect('/dashboard');
});
Version note: 5.0.0 is a major bump from 4.1.0 and the release notes are only a changelog link. Before adopting, diff the composer.json constraints and the Provider class on GitHub — for SocialiteProviders packages a major usually means raised PHP/Laravel Socialite minimums and possibly namespacing or scope-handling changes. Pin the version you validated.
Scope selection is the main lever. Atlassian grants nothing by default; the token's abilities are fixed at authorize time. Rather than scattering scopes() calls, centralise them:
final class JiraScopes
{
public const LOGIN = ['read:jira-user'];
public const WORK = ['read:jira-work', 'write:jira-work'];
public const OFFLINE = ['offline_access'];
public static function for(string $intent): array
{
return match ($intent) {
'login' => [...self::LOGIN],
'sync' => [...self::LOGIN, ...self::WORK, ...self::OFFLINE],
};
}
}
Requesting offline_access only when you genuinely need scheduled sync keeps your consent screen small and avoids storing refresh tokens you never use.
Resolve the cloud ID once, then cache it. Jira REST endpoints are site-scoped: https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3/.... The cloud ID comes from https://api.atlassian.com/oauth/token/accessible-resources using the user's bearer token — it is not on the Socialite user object. Fetch it at callback time and persist it:
$resources = Http::withToken($jiraUser->token)
->get('https://api.atlassian.com/oauth/token/accessible-resources')
->throw()
->json();
$cloudId = collect($resources)->firstWhere('url', 'https://your-site.atlassian.net')['id'];
Cache it per user (Cache::remember("jira:cloud:{$user->id}", ...)) — it changes only if the site is migrated.
Wrap the API in one client. You will call Jira from controllers, jobs, and commands. One service class that handles cloud ID lookup, token refresh, and error translation pays for itself immediately:
class JiraClient
{
public function __construct(private User $user) {}
public function get(string $path, array $query = []): array
{
return Http::withToken($this->token())
->baseUrl("https://api.atlassian.com/ex/jira/{$this->user->jira_cloud_id}/rest/api/3")
->acceptJson()
->get($path, $query)
->throw()
->json();
}
private function token(): string
{
if ($this->user->jira_expires_at->isFuture()) {
return $this->user->jira_access_token;
}
$fresh = Socialite::driver('jira')->refreshToken($this->user->jira_refresh_token);
$this->user->update([
'jira_access_token' => $fresh->token,
'jira_refresh_token' => $fresh->refreshToken ?? $this->user->jira_refresh_token,
'jira_expires_at' => now()->addSeconds($fresh->expiresIn),
]);
return $fresh->token;
}
}
Separate authentication from authorisation. The 3LO flow proves the user controls an Atlassian account. It does not prove they belong in your app. Gate on email domain, an account_id allowlist, or a workspace membership check after the callback — the same way you would with any SSO provider.
Queue the heavy work. Webhook ingestion and JQL sweeps will exceed a request's lifetime on any real site. Dispatch a job from the callback, pass the User id (not the token), and let the job resolve credentials through JiraClient.
Test without touching Atlassian. Socialite's fake() works with custom drivers registered through the manager, so you can assert the redirect and callback without network calls. For the API layer, fake Http::fake([...]) at the JiraClient boundary — and add one test that asserts you build the ex/jira/{cloudId} URL correctly, since that is where the silent 404s come from.
The callback URL must match byte-for-byte. Atlassian compares scheme, host, path, and trailing slash. http vs https behind a proxy is the classic failure: set TrustProxies or URL::forceScheme('https') so Laravel generates the same URL you registered.
InvalidStateException on callback almost always means the session was lost between redirect and return — different subdomain, a load balancer without sticky sessions, or a callback route outside the web middleware group. Confirm the callback route is session-backed.
Refresh tokens rotate. Atlassian's 3LO refresh tokens are single-use; each refresh returns a new one. Always persist $fresh->refreshToken and fall back to the old value only when the response omits it. Losing a rotated token silently kills the integration until re-consent, and it will only surface hours later in a queued job.
Email may be null. Jira returns an account_id, not necessarily an email — it depends on scope and the site's privacy settings. Never key your local users on email; use account_id and treat email as optional enrichment.
Scopes are classic vs granular. Atlassian's newer granular scopes behave differently from the classic ones, and a mismatch produces 401/403 responses that look like token problems but are scope problems. Decode the access token (or read the consent screen) and confirm what was actually granted before you debug your HTTP client.
401 doesn't always mean "refresh". Distinguish expired-token 401 from revoked-consent 401. Revoked consent requires the user to re-authorise; retrying a refresh in a loop just generates noise. Check for the refresh call itself failing, mark the integration as broken on the user record, and surface a reconnect prompt.
Config cache bites during setup. After adding keys to config/services.php, run php artisan config:clear — a cached config is the second most common cause of Driver [jira] is not supported. alongside the missing listener.
Extension points if the driver falls short. The provider is a thin subclass of Socialite's AbstractProvider. Override getAuthUrl, getTokenUrl, or the user-mapping method in your own subclass and register that with SocialiteWasCalled instead — cleaner than post-processing the returned User and it survives minor version bumps. For anything the driver does not expose (cloud ID, /myself fields), extend via the HTTP client, not by forking the package.
Pin and watch 5.0.0. Since the release notes carry no migration detail, treat upgrading from 4.1.0 as an unknown: check the package's composer.json for PHP and laravel/socialite constraints, skim the Provider diff for changed default scopes or endpoint URLs, and run your callback + refresh tests against a real Atlassian site before rolling it out.
How can I help you explore Laravel packages today?