Laravel AT Protocol Client (alpha & unstable)
1<?php
2
3namespace SocialDept\AtpClient\Auth;
4
5/**
6 * Manages OAuth client metadata for AT Protocol authentication.
7 *
8 * The client_id in atproto OAuth is a URL that serves as both the unique
9 * identifier and the location of the client metadata document.
10 *
11 * For production: Use an HTTPS URL pointing to your client metadata.
12 * For localhost: Use exactly 'http://localhost' (no port).
13 *
14 * @see https://atproto.com/specs/oauth#clients
15 */
16class ClientMetadataManager
17{
18 /**
19 * Get the client ID (URL to client metadata document).
20 *
21 * For production clients, this is an HTTPS URL like:
22 * 'https://example.com/oauth/client-metadata.json'
23 *
24 * For localhost development, this must be exactly 'http://localhost'
25 * (no port number allowed per atproto spec).
26 */
27 public function getClientId(): string
28 {
29 $clientId = config('client.client.client_id');
30
31 if ($clientId) {
32 return $clientId;
33 }
34
35 // Fall back to auto-generated client_id based on app URL
36 return $this->generateClientId();
37 }
38
39 /**
40 * Check if this is a localhost development client.
41 */
42 public function isLocalhost(): bool
43 {
44 return str_starts_with($this->getClientId(), 'http://localhost');
45 }
46
47 /**
48 * Get the redirect URIs.
49 *
50 * For localhost development, redirect URIs must use 127.0.0.1
51 * (not localhost) and can include a port number.
52 *
53 * @return array<string>
54 */
55 public function getRedirectUris(): array
56 {
57 $uris = config('client.client.redirect_uris', []);
58
59 if (! empty($uris)) {
60 return $uris;
61 }
62
63 // Default redirect URI based on environment
64 if ($this->isLocalhost()) {
65 // For localhost, use 127.0.0.1
66 return ['http://127.0.0.1'];
67 }
68
69 // For production, use app URL
70 return [config('client.client.url').'/auth/atp/callback'];
71 }
72
73 /**
74 * Get the OAuth scopes.
75 *
76 * @return array<string>
77 */
78 public function getScopes(): array
79 {
80 return config('client.client.scopes', ['atproto', 'transition:generic']);
81 }
82
83 /**
84 * Get the client metadata as an array.
85 *
86 * This is the structure served at the client_id URL.
87 *
88 * @return array<string, mixed>
89 */
90 public function toArray(): array
91 {
92 return [
93 'client_id' => $this->getClientId(),
94 'client_name' => config('client.client.name'),
95 'client_uri' => config('client.client.url'),
96 'redirect_uris' => $this->getRedirectUris(),
97 'scope' => implode(' ', $this->getScopes()),
98 'grant_types' => [
99 'authorization_code',
100 'refresh_token',
101 ],
102 'response_types' => ['code'],
103 'token_endpoint_auth_method' => 'none',
104 'application_type' => 'web',
105 'dpop_bound_access_tokens' => true,
106 ];
107 }
108
109 /**
110 * Generate client_id from app configuration.
111 *
112 * In production, points to the package's client-metadata.json endpoint.
113 * For localhost detection, checks if app URL contains localhost or .test.
114 *
115 * For localhost clients, the client_id includes query parameters for
116 * redirect_uri and scope since there's no metadata document to fetch.
117 *
118 * @see https://atproto.com/specs/oauth#clients
119 */
120 protected function generateClientId(): string
121 {
122 $appUrl = config('client.client.url') ?? config('app.url');
123 $host = parse_url($appUrl, PHP_URL_HOST);
124
125 // Detect local development environments
126 if ($this->isLocalDevelopment($host)) {
127 return $this->buildLocalhostClientId();
128 }
129
130 // Production: point to client metadata endpoint
131 $prefix = config('client.oauth.prefix', '/atp/oauth/');
132
133 return rtrim($appUrl, '/').rtrim($prefix, '/').'/client-metadata.json';
134 }
135
136 /**
137 * Build localhost client_id with query parameters.
138 *
139 * For localhost clients, metadata is passed via query parameters:
140 * - redirect_uri: The callback URL (using 127.0.0.1)
141 * - scope: Space-separated list of requested scopes
142 */
143 protected function buildLocalhostClientId(): string
144 {
145 $params = [];
146
147 // Add redirect URI
148 $redirectUris = config('client.client.redirect_uris', []);
149 if (! empty($redirectUris)) {
150 $params['redirect_uri'] = $redirectUris[0];
151 } else {
152 $params['redirect_uri'] = 'http://127.0.0.1';
153 }
154
155 // Add scopes
156 $scopes = config('client.client.scopes', ['atproto']);
157 $params['scope'] = implode(' ', $scopes);
158
159 return 'http://localhost?'.http_build_query($params);
160 }
161
162 /**
163 * Check if the host indicates a local development environment.
164 */
165 protected function isLocalDevelopment(?string $host): bool
166 {
167 if (! $host) {
168 return false;
169 }
170
171 return $host === 'localhost'
172 || $host === '127.0.0.1'
173 || str_ends_with($host, '.localhost')
174 || str_ends_with($host, '.test')
175 || str_ends_with($host, '.local');
176 }
177}