Bluesky의 OAuth는 AT Protocol에 기반하고 있으며, GitHub나 Google 등 일반적인 Socialite 프로바이더와는 크게 다릅니다.
Bluesky의 OAuth는 다른 Socialite 프로바이더와는 구현이 근본적으로 다릅니다. 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:...) 또는 핸들 |
| 로그인 힌트 | 불필요 | 핸들 / 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 스코프
패키지는 3가지 주요 사용 사례를 지원하는 기본 OAuth 스코프로 설정되어 있습니다.
- Socialite 로그인 —
atproto, account:email, include:app.bsky.authViewAll로 사용자 인증과 이메일 액세스를 활성화합니다
- 게시 —
include:app.bsky.authCreatePosts와 blob:*/*로 게시 작성과 이미지·동영상 업로드를 허용합니다
- DM 알림 —
rpc:chat.bsky.convo.sendMessage와 rpc:chat.bsky.convo.getConvoForMembers로 알림용 다이렉트 메시지 전송을 활성화합니다
BLUESKY_OAUTH_SCOPE 환경 변수를 설정하여 스코프를 커스터마이즈할 수 있습니다.
BLUESKY_OAUTH_SCOPE="atproto account:email include:app.bsky.authViewAll"
이용 가능한 스코프의 상세는 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)
{
// 핸들, 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 세션에 저장
$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 핸들 | alice.bsky.social |
displayName() | 표시 이름 | Alice |
avatar() | 아바터 URL | https://... |
issuer() | PDS의 URL | https://bsky.social |
token() | 액세스 토큰 | |
refresh() | 리프레시 토큰 | |
모든 속성을 확인하려면 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의 재이용
세션에 저장한 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 세션을 사용할 수 없는 경우에는 DB에서 데이터를 취득해 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();
토큰 자동 갱신
리프레시 토큰은 한 번밖에 사용할 수 없으므로, 갱신 후에는 반드시 DB에 다시 저장해야 합니다. 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이 무효화되므로, DB에서 삭제해 두면 안전합니다.
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 트레이트
User 모델에 WithBluesky 트레이트를 추가하고 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이거나 리프레시 토큰이 없는 경우, Unauthenticated 예외가 throw되고 login 라우트로 리디렉트됩니다.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->redirectGuestsTo('/bluesky/login');
})