diff --git a/src/Events/TokenRefreshed.php b/src/Events/TokenRefreshed.php new file mode 100644 index 0000000..de1b8ff --- /dev/null +++ b/src/Events/TokenRefreshed.php @@ -0,0 +1,17 @@ +credentials->identifier; + } + + public function did(): string + { + return $this->credentials->did; + } + + public function accessToken(): string + { + return $this->credentials->accessToken; + } + + public function refreshToken(): string + { + return $this->credentials->refreshToken; + } + + public function dpopKey(): DPoPKey + { + return $this->dpopKey; + } + + public function pdsEndpoint(): string + { + return $this->pdsEndpoint; + } + + public function isExpired(): bool + { + return $this->credentials->isExpired(); + } + + public function expiresIn(): int + { + return $this->credentials->expiresIn(); + } + + public function withCredentials(Credentials $credentials): self + { + return new self($credentials, $this->dpopKey, $this->pdsEndpoint); + } +} diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php new file mode 100644 index 0000000..a6bd2bc --- /dev/null +++ b/src/Session/SessionManager.php @@ -0,0 +1,140 @@ +sessions[$identifier])) { + $this->sessions[$identifier] = $this->createSession($identifier); + } + + return $this->sessions[$identifier]; + } + + /** + * Ensure session is valid, refresh if needed + */ + public function ensureValid(string $identifier): Session + { + $session = $this->session($identifier); + + // Check if token needs refresh + if ($session->expiresIn() < $this->refreshThreshold) { + $session = $this->refreshSession($session); + } + + return $session; + } + + /** + * Create session from app password + */ + public function fromAppPassword( + string $identifier, + string $password + ): Session { + $pdsEndpoint = Resolver::resolvePds($identifier); + + $response = $this->http->post($pdsEndpoint.'/xrpc/com.atproto.server.createSession', [ + 'identifier' => $identifier, + 'password' => $password, + ]); + + if ($response->failed()) { + throw new AuthenticationException('Login failed'); + } + + $token = AccessToken::fromResponse($response->json()); + + // Store credentials + $this->credentials->storeCredentials($identifier, $token); + + return $this->createSession($identifier); + } + + /** + * Create session from credentials + */ + protected function createSession(string $identifier): Session + { + $creds = $this->credentials->getCredentials($identifier); + + if (! $creds) { + throw new SessionExpiredException("No credentials found for {$identifier}"); + } + + // Get or create DPoP key + $sessionId = 'session_'.hash('sha256', $creds->did); + $dpopKey = $this->keyStore->get($sessionId); + + if (! $dpopKey) { + $dpopKey = $this->dpopManager->generateKey($sessionId); + } + + // Resolve PDS endpoint + $pdsEndpoint = Resolver::resolvePds($creds->did); + + return new Session($creds, $dpopKey, $pdsEndpoint); + } + + /** + * Refresh session tokens + */ + protected function refreshSession(Session $session): Session + { + // Fire event before refresh (allows developers to invalidate old token) + event(new TokenRefreshing($session->identifier(), $session->refreshToken())); + + $newToken = $this->refresher->refresh( + refreshToken: $session->refreshToken(), + pdsEndpoint: $session->pdsEndpoint(), + dpopKey: $session->dpopKey(), + ); + + // Update credentials (CRITICAL: refresh tokens are single-use) + $this->credentials->updateCredentials( + $session->identifier(), + $newToken + ); + + // Fire event after successful refresh + event(new TokenRefreshed($session->identifier(), $newToken)); + + // Update session + $newCreds = $this->credentials->getCredentials($session->identifier()); + $newSession = $session->withCredentials($newCreds); + + // Update cached session + $this->sessions[$session->identifier()] = $newSession; + + return $newSession; + } +}