+197
-11
README.md
+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
+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
+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
+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
+2
src/Data/Credentials.php
+2
src/Providers/ArrayCredentialProvider.php
+2
src/Providers/ArrayCredentialProvider.php
+5
src/Session/Session.php
+5
src/Session/Session.php
+4
-3
src/Session/SessionManager.php
+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)