Bluesky 的 OAuth 基于 AT Protocol,与 GitHub、Google 等常规的 Socialite provider 有很大不同。
Bluesky 的 OAuth 在实现上与其他 Socialite provider 有本质区别。它使用 DPoP(Demonstrated Proof of Possession)和 PAR(Pushed Authorization Requests)端点。不需要 client_secret,而是使用私钥。
与常规 OAuth 的区别
| 项目 | 常规 Socialite | Bluesky Socialite |
|---|
| 认证方式 | OAuth 2.0 | AT Protocol OAuth(DPoP) |
| 客户端标识 | client_id + client_secret | 私钥 + client-metadata.json URL |
client_id | 已注册的固定字符串 | client-metadata.json 的 URL |
| 用户标识 | 邮箱地址等 | DID(did:plc:...)或 handle |
| 登录提示 | 不需要 | 可以传入 handle / DID / PDS URL |
认证流程
安装与配置
创建私钥
首先生成私钥。这一步无需在 Bluesky 上做任何注册。
php artisan bluesky:new-private-key
Please set this private key in .env
BLUESKY_OAUTH_PRIVATE_KEY="...url-safe base64 encoded key..."
将输出的值复制到 .env 中。
BLUESKY_OAUTH_PRIVATE_KEY="..."
Bluesky 无需注册 client_id 或 client_secret。仅仅配置好私钥即可使用 OAuth 认证。
默认的 OAuth scope
包默认配置了 3 个主要场景所需的 OAuth scope。
- Socialite 登录 —— 通过
atproto、account:email、include:app.bsky.authViewAll 启用用户认证与邮箱访问
- 发帖 —— 通过
include:app.bsky.authCreatePosts 和 blob:*/* 允许创建帖子并上传图片、视频
- DM 通知 —— 通过
rpc:chat.bsky.convo.sendMessage 和 rpc:chat.bsky.convo.getConvoForMembers 启用用于通知的 DM 发送
可以通过 BLUESKY_OAUTH_SCOPE 环境变量自定义 scope。
BLUESKY_OAUTH_SCOPE="atproto account:email include:app.bsky.authViewAll"
关于可用 scope 的详细说明,请参考 AT Protocol Permission Requests 文档。
本地开发
默认已经配置了 http://localhost 和 http://127.0.0.1:8000/,本地开发无需额外配置。
# 只有当你修改了端口时才需要设置
BLUESKY_CLIENT_ID=
BLUESKY_REDIRECT=http://127.0.0.1:8080/
生产环境
只要存在名为 bluesky.oauth.redirect 的路由,就不需要设置 .env。仅当你修改了默认路由名时才需要配置。
BLUESKY_REDIRECT=/bluesky/callback
路由配置
回调路由的路由名建议使用 bluesky.oauth.redirect,包内部会用到这个名称。
// routes/web.php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SocialiteController;
Route::get('login', [SocialiteController::class, 'login'])->name('login');
Route::match(['get', 'post'], 'redirect', [SocialiteController::class, 'redirect']);
Route::get('bluesky/callback', [SocialiteController::class, 'callback'])
->name('bluesky.oauth.redirect');
本地开发中的回调处理
本地开发时,Bluesky 回调的 URL 被固定为 http://127.0.0.1:8000/。在路由层做转发比较方便。
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/', function (Request $request) {
if (app()->isLocal() && $request->has('iss')) {
return to_route('bluesky.oauth.redirect', $request->query());
}
// ...
});
控制器实现
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
use Revolution\Bluesky\Session\OAuthSession;
class SocialiteController extends Controller
{
public function login(Request $request)
{
// 用于输入登录提示的表单页面
return view('login');
}
public function redirect(Request $request)
{
// 可以将 handle、DID 或 PDS URL 作为 login_hint 传入(可省略)
$hint = $request->input('login_hint');
$request->session()->put('hint', $hint);
return Socialite::driver('bluesky')
->hint($hint)
->redirect();
}
public function callback(Request $request)
{
if ($request->missing('code')) {
// 仅供开发时调试。生产环境请替换为合适的错误处理
dd($request);
}
$hint = $request->session()->pull('hint');
/** @var \Laravel\Socialite\Two\User $user */
$user = Socialite::driver('bluesky')
->hint($hint)
->user();
/** @var OAuthSession $session */
$session = $user->session;
// 将 OAuthSession 保存到 Laravel session 中
$request->session()->put('bluesky_session', $session->toArray());
$loginUser = User::updateOrCreate([
'did' => $session->did(),
], [
'iss' => $session->issuer(),
'handle' => $session->handle(),
'name' => $session->displayName(),
'avatar' => $session->avatar(),
'access_token' => $session->token(),
'refresh_token' => $session->refresh(),
]);
auth()->login($loginUser, true);
return to_route('bluesky.dashboard');
}
}
用户信息(OAuthSession)
以下是可以从 $user->session 获取的 OAuthSession 主要方法。
| 方法 | 说明 | 示例 |
|---|
did() | Bluesky DID | did:plc:xxxxxx |
handle() | Bluesky handle | alice.bsky.social |
displayName() | 显示名 | Alice |
avatar() | 头像 URL | https://... |
issuer() | PDS 的 URL | https://bsky.social |
token() | access token | |
refresh() | refresh token | |
要查看所有属性,可以使用 toArray()。
/** @var OAuthSession $session */
dump($session->toArray());
数据库配置
在 users 表中添加 Bluesky 相关字段。DID 是 Bluesky 用户的唯一标识符。
// database/migrations/xxxx_add_bluesky_columns_to_users_table.php
Schema::table('users', function (Blueprint $table) {
$table->string('did')->nullable()->unique();
$table->string('iss')->nullable();
$table->string('handle')->nullable();
$table->string('avatar')->nullable();
$table->text('access_token')->nullable();
$table->text('refresh_token')->nullable();
});
复用 OAuthSession
可以使用保存在 session 中的 OAuthSession 来调用 API。
use Revolution\Bluesky\Facades\Bluesky;
use Revolution\Bluesky\Session\OAuthSession;
$session = OAuthSession::create(session('bluesky_session'));
$timeline = Bluesky::withToken($session)->getTimeline();
在 Job、Console 等无法使用 Laravel session 的场景中,可以从数据库读取数据后组装 OAuthSession。
use Revolution\Bluesky\Facades\Bluesky;
use Revolution\Bluesky\Session\OAuthSession;
$session = OAuthSession::create([
'did' => $user->did,
'refresh_token' => $user->refresh_token,
// 对于非 bsky.social 的账号,也要指定 iss
// 'iss' => $user->iss,
]);
$timeline = Bluesky::withToken($session)
->refreshSession()
->getTimeline();
自动刷新 token
refresh token 只能使用一次,因此更新后一定要重新写回数据库。这可以通过 OAuthSessionUpdated 事件实现。
php artisan make:listener OAuthSessionUpdatedListener
namespace App\Listeners;
use App\Models\User;
use Revolution\Bluesky\Events\OAuthSessionUpdated;
class OAuthSessionUpdatedListener
{
public function handle(OAuthSessionUpdated $event): void
{
if (empty($event->session->did())) {
return;
}
session()->put('bluesky_session', $event->session->toArray());
User::firstWhere('did', $event->session->did())
->fill([
'iss' => $event->session->issuer(),
'handle' => $event->session->handle(),
'name' => $event->session->displayName(),
'avatar' => $event->session->avatar(),
'access_token' => $event->session->token(),
'refresh_token' => $event->session->refresh(),
])->save();
}
}
在刷新开始时还会派发 OAuthSessionRefreshing 事件。此时 refresh_token 已失效,建议先从数据库中删除它,更加安全。
use Revolution\Bluesky\Events\OAuthSessionRefreshing;
public function handle(OAuthSessionRefreshing $event): void
{
if (empty($event->session->did())) {
return;
}
User::firstWhere('did', $event->session->did())
->fill(['refresh_token' => ''])
->save();
}
WithBluesky trait
给 User 模型加上 WithBluesky trait 并实现 tokenForBluesky(),就可以通过 $user->bluesky() 获取已认证的客户端。
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Revolution\Bluesky\Session\OAuthSession;
use Revolution\Bluesky\Traits\WithBluesky;
class User extends Authenticatable
{
use WithBluesky;
protected function tokenForBluesky(): OAuthSession
{
return OAuthSession::create([
'did' => $this->did,
'refresh_token' => $this->refresh_token,
'iss' => $this->iss,
]);
}
}
$profile = auth()->user()
->bluesky()
->refreshSession()
->getProfile();
包会自动定义 bluesky.oauth.client-metadata 和 bluesky.oauth.jwks 路由。通常不需要修改,但你可以通过 OAuthConfig 进行自定义。
// AppServiceProvider
use Revolution\Bluesky\Socialite\OAuthConfig;
public function boot(): void
{
OAuthConfig::clientMetadataUsing(function () {
return collect(config('bluesky.oauth.metadata'))
->merge([
'client_id' => route('bluesky.oauth.client-metadata'),
'jwks_uri' => route('bluesky.oauth.jwks'),
'redirect_uris' => [url('bluesky/callback')],
])
->reject(fn ($item) => is_null($item))
->toArray();
});
}
未认证时的行为
如果 OAuthSession 为 null 或没有 refresh token,会抛出 Unauthenticated 异常并重定向到 login 路由。
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->redirectGuestsTo('/bluesky/login');
})