Laravel AT Protocol Client (alpha & unstable)
1<?php
2
3namespace SocialDept\AtpClient\Session;
4
5use Illuminate\Support\Facades\Http;
6use SocialDept\AtpClient\Auth\DPoPKeyManager;
7use SocialDept\AtpClient\Auth\TokenRefresher;
8use SocialDept\AtpClient\Contracts\CredentialProvider;
9use SocialDept\AtpClient\Contracts\KeyStore;
10use SocialDept\AtpClient\Data\AccessToken;
11use SocialDept\AtpClient\Events\SessionAuthenticated;
12use SocialDept\AtpClient\Events\SessionRefreshing;
13use SocialDept\AtpClient\Events\SessionUpdated;
14use SocialDept\AtpClient\Exceptions\AuthenticationException;
15use SocialDept\AtpClient\Exceptions\HandleResolutionException;
16use SocialDept\AtpClient\Exceptions\SessionExpiredException;
17use SocialDept\AtpResolver\Facades\Resolver;
18use SocialDept\AtpResolver\Support\Identity;
19
20class SessionManager
21{
22 protected array $sessions = [];
23
24 public function __construct(
25 protected CredentialProvider $credentials,
26 protected TokenRefresher $refresher,
27 protected DPoPKeyManager $dpopManager,
28 protected KeyStore $keyStore,
29 protected int $refreshThreshold = 300, // 5 minutes
30 ) {}
31
32 /**
33 * Resolve an actor (handle or DID) to a DID.
34 *
35 * @throws HandleResolutionException
36 */
37 protected function resolveToDid(string $actor): string
38 {
39 // If already a DID, return as-is
40 if (Identity::isDid($actor)) {
41 return $actor;
42 }
43
44 // Resolve handle to DID
45 $did = Resolver::handleToDid($actor);
46
47 if (! $did) {
48 throw new HandleResolutionException($actor);
49 }
50
51 return $did;
52 }
53
54 /**
55 * Get or create session for an actor.
56 */
57 public function session(string $actor): Session
58 {
59 $did = $this->resolveToDid($actor);
60
61 if (! isset($this->sessions[$did])) {
62 $this->sessions[$did] = $this->createSession($did);
63 }
64
65 return $this->sessions[$did];
66 }
67
68 /**
69 * Ensure session is valid, refresh if needed.
70 */
71 public function ensureValid(string $actor): Session
72 {
73 $session = $this->session($actor);
74
75 // Check if token needs refresh
76 if ($session->expiresIn() < $this->refreshThreshold) {
77 $session = $this->refreshSession($session);
78 }
79
80 return $session;
81 }
82
83 /**
84 * Create session from app password.
85 */
86 public function fromAppPassword(
87 string $actor,
88 string $password
89 ): Session {
90 $did = $this->resolveToDid($actor);
91 $pdsEndpoint = Resolver::resolvePds($did);
92
93 $response = Http::post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [
94 'identifier' => $actor,
95 'password' => $password,
96 ]);
97
98 if ($response->failed()) {
99 throw new AuthenticationException('Login failed');
100 }
101
102 $token = AccessToken::fromResponse($response->json(), $actor, $pdsEndpoint);
103
104 // Store credentials using DID as key
105 $this->credentials->storeCredentials($did, $token);
106
107 event(new SessionAuthenticated($token));
108
109 return $this->createSession($did);
110 }
111
112 /**
113 * Create session from credentials
114 */
115 protected function createSession(string $did): Session
116 {
117 $creds = $this->credentials->getCredentials($did);
118
119 if (! $creds) {
120 throw new SessionExpiredException("No credentials found for {$did}");
121 }
122
123 // Get or create DPoP key
124 $sessionId = 'session_'.hash('sha256', $creds->did);
125 $dpopKey = $this->keyStore->get($sessionId);
126
127 if (! $dpopKey) {
128 $dpopKey = $this->dpopManager->generateKey($sessionId);
129 }
130
131 // Use stored issuer if available, otherwise resolve PDS endpoint
132 $pdsEndpoint = $creds->issuer ?? Resolver::resolvePds($creds->did);
133
134 return new Session($creds, $dpopKey, $pdsEndpoint);
135 }
136
137 /**
138 * Refresh session tokens
139 */
140 protected function refreshSession(Session $session): Session
141 {
142 $did = $session->did();
143
144 // Fire event before refresh (allows developers to invalidate old token)
145 event(new SessionRefreshing($session));
146
147 $newToken = $this->refresher->refresh(
148 refreshToken: $session->refreshToken(),
149 pdsEndpoint: $session->pdsEndpoint(),
150 dpopKey: $session->dpopKey(),
151 handle: $session->handle(),
152 authType: $session->authType(),
153 );
154
155 // Update credentials (CRITICAL: refresh tokens are single-use)
156 $this->credentials->updateCredentials($did, $newToken);
157
158 // Fire event after successful refresh
159 event(new SessionUpdated($session, $newToken));
160
161 // Update session
162 $newCreds = $this->credentials->getCredentials($did);
163 $newSession = $session->withCredentials($newCreds);
164
165 // Update cached session
166 $this->sessions[$did] = $newSession;
167
168 return $newSession;
169 }
170}