Laravel AT Protocol Client (alpha & unstable)

Merge branch 'dev'

+197 -11
README.md
··· 478 478 479 479 ## Credential Storage 480 480 481 - The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end). 481 + The package uses a `CredentialProvider` interface for token storage. The default `ArrayCredentialProvider` stores credentials in memory (lost on request end). For production applications, you need to implement persistent storage. 482 + 483 + ### Why You Need a Credential Provider 484 + 485 + AT Protocol OAuth uses **single-use refresh tokens**. When a token is refreshed: 486 + 1. The old refresh token is immediately invalidated 487 + 2. A new refresh token is issued 488 + 3. You must store the new token before using it again 482 489 483 - ### Implementing Custom Storage 490 + If you lose the refresh token, the user must re-authenticate. The `CredentialProvider` ensures tokens are safely persisted. 491 + 492 + ### The CredentialProvider Interface 484 493 485 494 ```php 495 + interface CredentialProvider 496 + { 497 + // Get stored credentials for a user 498 + public function getCredentials(string $identifier): ?Credentials; 499 + 500 + // Store credentials after initial OAuth or app password login 501 + public function storeCredentials(string $identifier, AccessToken $token): void; 502 + 503 + // Update credentials after token refresh (CRITICAL: refresh tokens are single-use!) 504 + public function updateCredentials(string $identifier, AccessToken $token): void; 505 + 506 + // Remove credentials (logout) 507 + public function removeCredentials(string $identifier): void; 508 + } 509 + ``` 510 + 511 + ### Database Migration 512 + 513 + Create a migration for storing credentials: 514 + 515 + ```bash 516 + php artisan make:migration create_atp_credentials_table 517 + ``` 518 + 519 + ```php 520 + Schema::create('atp_credentials', function (Blueprint $table) { 521 + $table->id(); 522 + $table->string('identifier')->unique(); // User handle or DID 523 + $table->string('did'); // Decentralized identifier 524 + $table->string('handle')->nullable(); // User's handle (e.g., user.bsky.social) 525 + $table->string('issuer')->nullable(); // PDS endpoint URL 526 + $table->text('access_token'); // JWT access token 527 + $table->text('refresh_token'); // Single-use refresh token 528 + $table->timestamp('expires_at'); // Token expiration time 529 + $table->timestamps(); 530 + 531 + $table->index('did'); 532 + }); 533 + ``` 534 + 535 + ### Implementing a Database Provider 536 + 537 + ```php 538 + <?php 539 + 540 + namespace App\Providers; 541 + 542 + use App\Models\AtpCredential; 486 543 use SocialDept\AtpClient\Contracts\CredentialProvider; 487 544 use SocialDept\AtpClient\Data\AccessToken; 488 545 use SocialDept\AtpClient\Data\Credentials; ··· 493 550 { 494 551 $record = AtpCredential::where('identifier', $identifier)->first(); 495 552 496 - if (!$record) { 553 + if (! $record) { 497 554 return null; 498 555 } 499 556 ··· 503 560 accessToken: $record->access_token, 504 561 refreshToken: $record->refresh_token, 505 562 expiresAt: $record->expires_at, 563 + handle: $record->handle, 564 + issuer: $record->issuer, 506 565 ); 507 566 } 508 567 509 568 public function storeCredentials(string $identifier, AccessToken $token): void 510 569 { 511 - AtpCredential::create([ 512 - 'identifier' => $identifier, 513 - 'did' => $token->did, 514 - 'access_token' => $token->accessJwt, 515 - 'refresh_token' => $token->refreshJwt, 516 - 'expires_at' => $token->expiresAt, 517 - ]); 570 + AtpCredential::updateOrCreate( 571 + ['identifier' => $identifier], 572 + [ 573 + 'did' => $token->did, 574 + 'handle' => $token->handle, 575 + 'issuer' => $token->issuer, 576 + 'access_token' => $token->accessJwt, 577 + 'refresh_token' => $token->refreshJwt, 578 + 'expires_at' => $token->expiresAt, 579 + ] 580 + ); 518 581 } 519 582 520 583 public function updateCredentials(string $identifier, AccessToken $token): void ··· 523 586 'access_token' => $token->accessJwt, 524 587 'refresh_token' => $token->refreshJwt, 525 588 'expires_at' => $token->expiresAt, 589 + // Preserve handle and issuer, or update if provided 590 + 'handle' => $token->handle, 591 + 'issuer' => $token->issuer, 526 592 ]); 527 593 } 528 594 ··· 533 599 } 534 600 ``` 535 601 536 - Register your provider in the config: 602 + ### The AtpCredential Model 537 603 538 604 ```php 605 + <?php 606 + 607 + namespace App\Models; 608 + 609 + use Illuminate\Database\Eloquent\Model; 610 + 611 + class AtpCredential extends Model 612 + { 613 + protected $fillable = [ 614 + 'identifier', 615 + 'did', 616 + 'handle', 617 + 'issuer', 618 + 'access_token', 619 + 'refresh_token', 620 + 'expires_at', 621 + ]; 622 + 623 + protected $casts = [ 624 + 'expires_at' => 'datetime', 625 + ]; 626 + 627 + protected $hidden = [ 628 + 'access_token', 629 + 'refresh_token', 630 + ]; 631 + } 632 + ``` 633 + 634 + ### Register Your Provider 635 + 636 + Update your config file: 637 + 638 + ```php 639 + // config/client.php 640 + 539 641 'credential_provider' => App\Providers\DatabaseCredentialProvider::class, 642 + ``` 643 + 644 + Or bind it in a service provider: 645 + 646 + ```php 647 + // app/Providers/AppServiceProvider.php 648 + 649 + use SocialDept\AtpClient\Contracts\CredentialProvider; 650 + use App\Providers\DatabaseCredentialProvider; 651 + 652 + public function register(): void 653 + { 654 + $this->app->singleton(CredentialProvider::class, DatabaseCredentialProvider::class); 655 + } 656 + ``` 657 + 658 + ### Linking to Your User Model 659 + 660 + If you want to associate ATP credentials with your application's users: 661 + 662 + ```php 663 + // Migration 664 + Schema::table('atp_credentials', function (Blueprint $table) { 665 + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); 666 + }); 667 + 668 + // AtpCredential model 669 + public function user() 670 + { 671 + return $this->belongsTo(User::class); 672 + } 673 + 674 + // User model 675 + public function atpCredential() 676 + { 677 + return $this->hasOne(AtpCredential::class); 678 + } 679 + ``` 680 + 681 + Then update your provider to work with the authenticated user: 682 + 683 + ```php 684 + public function storeCredentials(string $identifier, AccessToken $token): void 685 + { 686 + AtpCredential::updateOrCreate( 687 + ['identifier' => $identifier], 688 + [ 689 + 'user_id' => auth()->id(), // Link to current user 690 + 'did' => $token->did, 691 + 'handle' => $token->handle, 692 + 'issuer' => $token->issuer, 693 + 'access_token' => $token->accessJwt, 694 + 'refresh_token' => $token->refreshJwt, 695 + 'expires_at' => $token->expiresAt, 696 + ] 697 + ); 698 + } 699 + ``` 700 + 701 + ### Understanding the Credential Fields 702 + 703 + | Field | Description | 704 + |-------|-------------| 705 + | `identifier` | The key used to look up credentials (usually the handle) | 706 + | `did` | Decentralized Identifier (e.g., `did:plc:abc123...`) | 707 + | `handle` | User's handle (e.g., `user.bsky.social`) | 708 + | `issuer` | The user's PDS endpoint URL (avoids repeated lookups) | 709 + | `accessToken` | JWT for API authentication (short-lived) | 710 + | `refreshToken` | Token to get new access tokens (single-use!) | 711 + | `expiresAt` | When the access token expires | 712 + 713 + ### Handling Token Refresh Events 714 + 715 + When tokens are automatically refreshed, you can listen for events: 716 + 717 + ```php 718 + use SocialDept\AtpClient\Events\TokenRefreshed; 719 + 720 + // In EventServiceProvider or via Event::listen() 721 + Event::listen(TokenRefreshed::class, function (TokenRefreshed $event) { 722 + // The CredentialProvider.updateCredentials() is already called, 723 + // but you can do additional logging or notifications here 724 + Log::info("Token refreshed for: {$event->identifier}"); 725 + }); 540 726 ``` 541 727 542 728 ## Events
+1 -1
src/Auth/OAuthEngine.php
··· 98 98 throw new AuthenticationException('Token exchange failed: '.$response->body()); 99 99 } 100 100 101 - return AccessToken::fromResponse($response->json(), $request->handle); 101 + return AccessToken::fromResponse($response->json(), $request->handle, $request->pdsEndpoint); 102 102 } 103 103 104 104 /**
+3 -2
src/Auth/TokenRefresher.php
··· 20 20 public function refresh( 21 21 string $refreshToken, 22 22 string $pdsEndpoint, 23 - DPoPKey $dpopKey 23 + DPoPKey $dpopKey, 24 + ?string $handle = null 24 25 ): AccessToken { 25 26 $tokenUrl = $pdsEndpoint.'/oauth/token'; 26 27 ··· 35 36 throw new AuthenticationException('Token refresh failed: '.$response->body()); 36 37 } 37 38 38 - return AccessToken::fromResponse($response->json()); 39 + return AccessToken::fromResponse($response->json(), $handle, $pdsEndpoint); 39 40 } 40 41 }
+4 -1
src/Data/AccessToken.php
··· 10 10 public readonly string $did, 11 11 public readonly \DateTimeInterface $expiresAt, 12 12 public readonly ?string $handle = null, 13 + public readonly ?string $issuer = null, 13 14 ) {} 14 15 15 16 /** ··· 18 19 * Handles both legacy createSession format (accessJwt, refreshJwt, did) 19 20 * and OAuth token format (access_token, refresh_token, sub). 20 21 */ 21 - public static function fromResponse(array $data, ?string $handle = null): self 22 + public static function fromResponse(array $data, ?string $handle = null, ?string $issuer = null): self 22 23 { 23 24 // OAuth token endpoint format 24 25 if (isset($data['access_token'])) { ··· 28 29 did: $data['sub'] ?? '', 29 30 expiresAt: now()->addSeconds($data['expires_in'] ?? 300), 30 31 handle: $handle, 32 + issuer: $issuer, 31 33 ); 32 34 } 33 35 ··· 38 40 did: $data['did'], 39 41 expiresAt: now()->addSeconds($data['expiresIn'] ?? 300), 40 42 handle: $data['handle'] ?? $handle, 43 + issuer: $issuer, 41 44 ); 42 45 } 43 46 }
+2
src/Data/Credentials.php
··· 10 10 public readonly string $accessToken, 11 11 public readonly string $refreshToken, 12 12 public readonly \DateTimeInterface $expiresAt, 13 + public readonly ?string $handle = null, 14 + public readonly ?string $issuer = null, 13 15 ) {} 14 16 15 17 public function isExpired(): bool
+2
src/Providers/ArrayCredentialProvider.php
··· 28 28 accessToken: $token->accessJwt, 29 29 refreshToken: $token->refreshJwt, 30 30 expiresAt: $token->expiresAt, 31 + handle: $token->handle, 32 + issuer: $token->issuer, 31 33 ); 32 34 } 33 35
+5
src/Session/Session.php
··· 23 23 return $this->credentials->did; 24 24 } 25 25 26 + public function handle(): ?string 27 + { 28 + return $this->credentials->handle; 29 + } 30 + 26 31 public function accessToken(): string 27 32 { 28 33 return $this->credentials->accessToken;
+4 -3
src/Session/SessionManager.php
··· 71 71 throw new AuthenticationException('Login failed'); 72 72 } 73 73 74 - $token = AccessToken::fromResponse($response->json()); 74 + $token = AccessToken::fromResponse($response->json(), $identifier, $pdsEndpoint); 75 75 76 76 // Store credentials 77 77 $this->credentials->storeCredentials($identifier, $token); ··· 98 98 $dpopKey = $this->dpopManager->generateKey($sessionId); 99 99 } 100 100 101 - // Resolve PDS endpoint 102 - $pdsEndpoint = Resolver::resolvePds($creds->did); 101 + // Use stored issuer if available, otherwise resolve PDS endpoint 102 + $pdsEndpoint = $creds->issuer ?? Resolver::resolvePds($creds->did); 103 103 104 104 return new Session($creds, $dpopKey, $pdsEndpoint); 105 105 } ··· 116 116 refreshToken: $session->refreshToken(), 117 117 pdsEndpoint: $session->pdsEndpoint(), 118 118 dpopKey: $session->dpopKey(), 119 + handle: $session->handle(), 119 120 ); 120 121 121 122 // Update credentials (CRITICAL: refresh tokens are single-use)