PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / oauth / class-scope.php

class-scope.php in ActivityPub trunk, at includes/oauth/class-scope.php

360 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OAuth 2.0 Scope definitions for ActivityPub C2S.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\OAuth;
9
10 /**
11 * Scope class for OAuth 2.0 scope management.
12 *
13 * Defines available scopes and provides validation methods.
14 */
15 class Scope {
16 /**
17 * Read access scope - read actor profile, collections, and objects.
18 */
19 const READ = 'read';
20
21 /**
22 * Write access scope - create activities via POST to outbox.
23 */
24 const WRITE = 'write';
25
26 /**
27 * Push access scope - subscribe to SSE streams.
28 */
29 const PUSH = 'push';
30
31 /**
32 * All available scopes.
33 *
34 * @var array
35 */
36 const ALL = array(
37 self::READ,
38 self::WRITE,
39 self::PUSH,
40 );
41
42 /**
43 * Scope aliases from the SWICG ActivityPub API Basic Profile, as it stood before 2026-08-04.
44 *
45 * Maps every alias the draft defined, including the `:sameorigin` variants, to a scope the
46 * plugin grants. That is coarser than the draft intended: there is no per-activity access
47 * control, so every write alias resolves to `write`, which permits any activity the actor
48 * can post. A client asking for `activitypub:write:like` is granted deleting and blocking
49 * along with it.
50 *
51 * @since 9.0.0
52 * @since 9.3.0 Changed from a list to an alias-to-scope map.
53 *
54 * @var array
55 */
56 const CANONICAL_ALIASES = array(
57 'activitypub:read:all' => self::READ,
58 'activitypub:read:local:all' => self::READ,
59 'activitypub:read:me:actor' => self::READ,
60 'activitypub:read:me:all' => self::READ,
61 'activitypub:read:me:followers' => self::READ,
62 'activitypub:read:me:following' => self::READ,
63 'activitypub:read:me:inbox' => self::READ,
64 'activitypub:read:me:liked' => self::READ,
65 'activitypub:read:me:outbox' => self::READ,
66 'activitypub:read:remote:all' => self::READ,
67 'activitypub:write:accept' => self::WRITE,
68 'activitypub:write:add' => self::WRITE,
69 'activitypub:write:add:sameorigin' => self::WRITE,
70 'activitypub:write:all' => self::WRITE,
71 'activitypub:write:all:sameorigin' => self::WRITE,
72 'activitypub:write:announce' => self::WRITE,
73 'activitypub:write:announce:sameorigin' => self::WRITE,
74 'activitypub:write:block' => self::WRITE,
75 'activitypub:write:block:sameorigin' => self::WRITE,
76 'activitypub:write:create' => self::WRITE,
77 'activitypub:write:create:inreplyto:sameorigin' => self::WRITE,
78 'activitypub:write:create:sameorigin' => self::WRITE,
79 'activitypub:write:delete' => self::WRITE,
80 'activitypub:write:delete:inreplyto:sameorigin' => self::WRITE,
81 'activitypub:write:delete:sameorigin' => self::WRITE,
82 'activitypub:write:flag' => self::WRITE,
83 'activitypub:write:flag:sameorigin' => self::WRITE,
84 'activitypub:write:follow' => self::WRITE,
85 'activitypub:write:follow:sameorigin' => self::WRITE,
86 'activitypub:write:like' => self::WRITE,
87 'activitypub:write:like:sameorigin' => self::WRITE,
88 'activitypub:write:question' => self::WRITE,
89 'activitypub:write:reject' => self::WRITE,
90 'activitypub:write:remove' => self::WRITE,
91 'activitypub:write:remove:sameorigin' => self::WRITE,
92 'activitypub:write:undo:all' => self::WRITE,
93 'activitypub:write:undo:all:sameorigin' => self::WRITE,
94 'activitypub:write:undo:announce' => self::WRITE,
95 'activitypub:write:undo:announce:sameorigin' => self::WRITE,
96 'activitypub:write:undo:block' => self::WRITE,
97 'activitypub:write:undo:block:sameorigin' => self::WRITE,
98 'activitypub:write:undo:follow' => self::WRITE,
99 'activitypub:write:undo:follow:sameorigin' => self::WRITE,
100 'activitypub:write:undo:like' => self::WRITE,
101 'activitypub:write:undo:like:sameorigin' => self::WRITE,
102 'activitypub:write:update' => self::WRITE,
103 'activitypub:write:update:inreplyto:sameorigin' => self::WRITE,
104 'activitypub:write:update:sameorigin' => self::WRITE,
105 );
106
107 /**
108 * Identifier prefix for the SWICG ActivityPub API Basic Profile scopes.
109 *
110 * @since 9.3.0
111 */
112 const CANONICAL_SCOPE_PREFIX = 'https://swicg.github.io/activitypub-api/scopes#';
113
114 /**
115 * Scope identifiers from the SWICG ActivityPub API Basic Profile, keyed by fragment.
116 *
117 * The Basic Profile replaced its `activitypub:*` aliases with these URL identifiers on
118 * 2026-08-04; {@see self::CANONICAL_ALIASES} is kept for clients built before that.
119 *
120 * Each identifier maps to a scope the plugin grants, which is coarser than the spec intends:
121 * the per-collection read identifiers all resolve to `read`, and the per-action write
122 * identifiers, `follow` and `updateprofile` included, to `write`. Seven identifiers are
123 * deliberately absent, because the plugin has nothing to grant for them: `readown` and
124 * `reactown` describe data on the client's own server rather than this one; `uploadfiles`
125 * needs a MediaUpload endpoint the plugin does not implement; and `addressall`,
126 * `addresspublic`, `addressactor` and `addressfollowers` narrow who an activity may be
127 * addressed to, which is a restriction on a write rather than a permission of its own.
128 * Resolving those to `write` would answer a request to be limited by handing over the
129 * unlimited version.
130 *
131 * @since 9.3.0
132 */
133 const CANONICAL_SCOPES = array(
134 'readall' => self::READ,
135 'readany' => self::READ,
136 'readlocal' => self::READ,
137 'readinbox' => self::READ,
138 'readoutbox' => self::READ,
139 'readfollowers' => self::READ,
140 'readfollowing' => self::READ,
141 'readliked' => self::READ,
142 'createcontent' => self::WRITE,
143 'updatecontent' => self::WRITE,
144 'deletecontent' => self::WRITE,
145 'managefollowers' => self::WRITE,
146 'managecollections' => self::WRITE,
147 'like' => self::WRITE,
148 'share' => self::WRITE,
149 'block' => self::WRITE,
150 'flag' => self::WRITE,
151 'reactlocal' => self::WRITE,
152 'reactany' => self::WRITE,
153 'follow' => self::WRITE,
154 'updateprofile' => self::WRITE,
155 );
156
157
158 /**
159 * Default scopes when none are requested.
160 *
161 * Defaults to read-only to prevent granting write access without
162 * explicit scope request (fail-closed on access control).
163 *
164 * @var array
165 */
166 const DEFAULT_SCOPES = array(
167 self::READ,
168 );
169
170 /**
171 * Validate and filter requested scopes.
172 *
173 * Basic Profile identifiers are normalized to the plugin's internal scopes before validation.
174 *
175 * @param string|array $scopes The requested scopes (space-separated string or array).
176 * @return array Valid scopes.
177 */
178 public static function validate( $scopes ) {
179 if ( \is_string( $scopes ) ) {
180 $scopes = self::parse( $scopes );
181 }
182
183 if ( ! \is_array( $scopes ) ) {
184 return self::DEFAULT_SCOPES;
185 }
186
187 $scopes = self::normalize( $scopes );
188 $valid_scopes = \array_intersect( $scopes, self::ALL );
189
190 if ( empty( $valid_scopes ) ) {
191 return self::DEFAULT_SCOPES;
192 }
193
194 return \array_values( \array_unique( $valid_scopes ) );
195 }
196
197 /**
198 * Normalize canonical Basic Profile scope names to internal scopes.
199 *
200 * Looks each requested scope up in {@see self::CANONICAL_ALIASES}. An exact lookup, so a
201 * scope means the same thing wherever it appears in the request. Unknown values pass through
202 * unchanged so they can be filtered out by the caller.
203 *
204 * @since 9.0.0
205 *
206 * @param array $scopes Requested scope strings.
207 * @return array Normalized scope strings.
208 */
209 public static function normalize( $scopes ) {
210 if ( ! \is_array( $scopes ) ) {
211 return array();
212 }
213
214 $normalized = array();
215 foreach ( $scopes as $scope ) {
216 if ( ! \is_string( $scope ) || '' === $scope ) {
217 continue;
218 }
219
220 if ( isset( self::CANONICAL_ALIASES[ $scope ] ) ) {
221 $normalized[] = self::CANONICAL_ALIASES[ $scope ];
222 continue;
223 }
224
225 if ( 0 === \strpos( $scope, self::CANONICAL_SCOPE_PREFIX ) ) {
226 $fragment = \substr( $scope, \strlen( self::CANONICAL_SCOPE_PREFIX ) );
227
228 if ( isset( self::CANONICAL_SCOPES[ $fragment ] ) ) {
229 $normalized[] = self::CANONICAL_SCOPES[ $fragment ];
230 continue;
231 }
232 }
233
234 $normalized[] = $scope;
235 }
236
237 return $normalized;
238 }
239
240 /**
241 * Return the scope identifiers advertised in OAuth authorization-server metadata.
242 *
243 * Includes the plugin's internal scopes plus the Basic Profile identifiers, in both the
244 * pre-2026-08-04 alias form and the URI form that replaced it, so spec-aware clients can
245 * discover them.
246 *
247 * @since 9.0.0
248 * @since 9.3.0 Also advertises the URI-form identifiers.
249 *
250 * @return array Scope identifiers.
251 */
252 public static function supported() {
253 $identifiers = \array_keys( self::CANONICAL_ALIASES );
254
255 foreach ( \array_keys( self::CANONICAL_SCOPES ) as $fragment ) {
256 $identifiers[] = self::CANONICAL_SCOPE_PREFIX . $fragment;
257 }
258
259 return \array_merge( self::ALL, $identifiers );
260 }
261
262 /**
263 * Parse a space-separated scope string to array.
264 *
265 * @param string $scope_string Space-separated scopes.
266 * @return array Scope array.
267 */
268 public static function parse( $scope_string ) {
269 if ( empty( $scope_string ) || ! \is_string( $scope_string ) ) {
270 return array();
271 }
272
273 $scopes = \preg_split( '/\s+/', \trim( $scope_string ) );
274
275 return \array_filter( \array_map( 'trim', $scopes ) );
276 }
277
278 /**
279 * Convert scopes array to space-separated string.
280 *
281 * @param array $scopes The scopes array.
282 * @return string Space-separated scope string.
283 */
284 public static function to_string( $scopes ) {
285 if ( ! \is_array( $scopes ) ) {
286 return '';
287 }
288
289 return \implode( ' ', $scopes );
290 }
291
292 /**
293 * Check if a scope is valid.
294 *
295 * @param string $scope The scope to check.
296 * @return bool True if valid, false otherwise.
297 */
298 public static function is_valid( $scope ) {
299 return \in_array( $scope, self::ALL, true );
300 }
301
302 /**
303 * Get the description for a scope.
304 *
305 * @param string $scope The scope.
306 * @return string The description or empty string if not found.
307 */
308 public static function get_description( $scope ) {
309 $descriptions = self::get_all_with_descriptions();
310
311 return $descriptions[ $scope ] ?? '';
312 }
313
314 /**
315 * Get all scopes with their descriptions.
316 *
317 * @return array Associative array of scope => description.
318 */
319 public static function get_all_with_descriptions() {
320 /*
321 * Built here rather than held in a constant: these are shown to the user on the consent
322 * screen, so they have to be translated, and a constant cannot hold a translated string.
323 */
324 return array(
325 self::READ => \__( 'Read actor profile, collections, and objects', 'activitypub' ),
326 self::WRITE => \__( 'Create activities via POST to outbox', 'activitypub' ),
327 self::PUSH => \__( 'Subscribe to real-time event streams', 'activitypub' ),
328 );
329 }
330
331 /**
332 * Check if scopes contain a specific scope.
333 *
334 * @param array $scopes The scopes to check.
335 * @param string $scope The scope to look for.
336 * @return bool True if the scope is present.
337 */
338 public static function contains( $scopes, $scope ) {
339 return \is_array( $scopes ) && \in_array( $scope, $scopes, true );
340 }
341
342 /**
343 * Sanitize callback for scope storage.
344 *
345 * @param mixed $value The value to sanitize.
346 * @return array Sanitized scopes array.
347 */
348 public static function sanitize( $value ) {
349 if ( \is_string( $value ) ) {
350 $value = self::parse( $value );
351 }
352
353 if ( ! \is_array( $value ) ) {
354 return array();
355 }
356
357 return self::validate( $value );
358 }
359 }
360