> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# Socialite - Laravel Bluesky

> Laravel Bluesky 的 OAuth 認證。使用 AT Protocol 專屬的 DPoP、PAR 端點的 Socialite 設定與用法。

## 概觀

Bluesky 的 OAuth 基於 AT Protocol，與 GitHub、Google 等一般 Socialite 提供者有很大差異。

<Warning>
  Bluesky 的 OAuth 在實作上與其他 Socialite 提供者根本不同。會使用 DPoP（Demonstrated Proof of Possession）與 PAR（Pushed Authorization Requests）端點。不需要 `client_secret`，改為使用私鑰。
</Warning>

### 與一般 OAuth 的差異

| 項目          | 一般 Socialite                  | Bluesky Socialite               |
| ----------- | ----------------------------- | ------------------------------- |
| 認證方式        | OAuth 2.0                     | AT Protocol OAuth（DPoP）         |
| Client 識別   | `client_id` + `client_secret` | 私鑰 + `client-metadata.json` URL |
| `client_id` | 已註冊的固定字串                      | `client-metadata.json` 的 URL    |
| 使用者識別碼      | email 位址等                     | DID（`did:plc:...`）或 handle      |
| 登入提示        | 不需要                           | 可傳入 handle / DID / PDS URL      |

### 認證流程

```mermaid theme={null}
sequenceDiagram
    participant User as 使用者
    participant App as Laravel<br>應用程式
    participant Bluesky as Bluesky<br>伺服器

    User->>App: 送出登入表單（輸入 handle）
    App->>Bluesky: PAR 請求（含 login_hint）
    Bluesky-->>App: request_uri
    App->>Bluesky: 重新導向至授權端點
    User->>Bluesky: 於 Bluesky 完成登入授權
    Bluesky-->>App: callback（code + iss）
    App->>Bluesky: 以 DPoP 進行 token 交換
    Bluesky-->>App: access token + OAuthSession
    App->>User: 登入完成
```

## 安裝與設定

### 建立私鑰

首先產生私鑰。此步驟無需向 Bluesky 註冊即可完成。

```bash theme={null}
php artisan bluesky:new-private-key
```

```
Please set this private key in .env

BLUESKY_OAUTH_PRIVATE_KEY="...url-safe base64 encoded key..."
```

將輸出的值複製到 `.env`。

```dotenv theme={null}
BLUESKY_OAUTH_PRIVATE_KEY="..."
```

<Info>
  Bluesky 不需要註冊 `client_id` 或 `client_secret`。僅設定私鑰即可使用 OAuth 認證。
</Info>

### 預設 OAuth scope

套件已使用支援三個主要使用情境的預設 OAuth scope 進行設定。

1. **Socialite 登入** — 透過 `atproto`、`account:email`、`include:app.bsky.authViewAll` 啟用使用者認證與 email 存取
2. **貼文** — 透過 `include:app.bsky.authCreatePosts` 與 `blob:*/*` 允許建立貼文與上傳圖像 / 影片
3. **DM 通知** — 透過 `rpc:chat.bsky.convo.sendMessage` 與 `rpc:chat.bsky.convo.getConvoForMembers` 啟用通知用的私訊發送

可透過設定 `BLUESKY_OAUTH_SCOPE` 環境變數自訂 scope。

```dotenv theme={null}
BLUESKY_OAUTH_SCOPE="atproto account:email include:app.bsky.authViewAll"
```

關於可用 scope 的詳細內容，請參考 [AT Protocol Permission Requests 文件](https://atproto.com/guides/permission-requests)。

### 本機開發

預設設定為 `http://localhost` 與 `http://127.0.0.1:8000/`，本機開發不需要額外設定。

```dotenv theme={null}
# 僅在變更連接埠時設定
BLUESKY_CLIENT_ID=
BLUESKY_REDIRECT=http://127.0.0.1:8080/
```

### 正式環境

若存在名為 `bluesky.oauth.redirect` 的路由，即無須設定 `.env`。若變更了預設路由名稱，才需設定。

```dotenv theme={null}
BLUESKY_REDIRECT=/bluesky/callback
```

## 路由設定

建議 callback 路由的名稱為 `bluesky.oauth.redirect`，套件在內部會使用此名稱。

```php theme={null}
// 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');
```

### 本機開發中的 callback 處理

本機開發期間，Bluesky 的 callback URL 會固定為 `http://127.0.0.1:8000/`。可在路由層級進行分派。

```php theme={null}
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());
    }

    // ...
});
```

## Controller 實作

```php theme={null}
<?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()`      | avatar URL     | `https://...`         |
| `issuer()`      | PDS 的 URL      | `https://bsky.social` |
| `token()`       | access token   |                       |
| `refresh()`     | refresh token  |                       |

如需確認所有屬性，可使用 `toArray()`。

```php theme={null}
/** @var OAuthSession $session */
dump($session->toArray());
```

## 資料庫設定

於 `users` 資料表新增 Bluesky 專屬欄位。DID 為 Bluesky 使用者的唯一識別碼。

```php theme={null}
// 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。

```php theme={null}
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 的情境，可從 DB 取得資料以組合 OAuthSession。

```php theme={null}
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 只能使用一次，因此更新後必須重新儲存至 DB。可使用 `OAuthSessionUpdated` 事件。

```bash theme={null}
php artisan make:listener OAuthSessionUpdatedListener
```

```php theme={null}
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 中刪除較為安全。

```php theme={null}
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()` 取得已認證的 client。

```php theme={null}
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,
        ]);
    }
}
```

```php theme={null}
$profile = auth()->user()
                 ->bluesky()
                 ->refreshSession()
                 ->getProfile();
```

## client-metadata 的自訂

套件會自動定義 `bluesky.oauth.client-metadata` 與 `bluesky.oauth.jwks` 路由。通常不需修改，但可透過 `OAuthConfig` 進行自訂。

```php theme={null}
// 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` 路由。

```php theme={null}
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->redirectGuestsTo('/bluesky/login');
})
```

<Info>
  Source：[docs/socialite.md](https://github.com/invokable/laravel-bluesky/blob/main/docs/socialite.md)
</Info>


## Related topics

- [Laravel Bluesky](/zh-TW/packages/laravel-bluesky/index.md)
- [Basic client - Laravel Bluesky](/zh-TW/packages/laravel-bluesky/basic-client.md)
- [Bot 教學 - Laravel Bluesky](/zh-TW/packages/laravel-bluesky/bot-tutorial.md)
- [Laravel Socialite（社群認證）](/zh-TW/socialite.md)
- [認證方式比較 - Laravel Bluesky](/zh-TW/packages/laravel-bluesky/authentication.md)
