PluginProbe
ActivityPub / 9.3.0
ActivityPub v9.3.0
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-authorization-code.php

class-authorization-code.php in ActivityPub 9.3.0, at includes/oauth/class-authorization-code.php

342 lines 10.4 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 Authorization Code model for ActivityPub C2S.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub\OAuth;
9
10 use Activitypub\Sanitize;
11
12 /**
13 * Authorization_Code class for managing OAuth 2.0 authorization codes.
14 *
15 * Authorization codes are short-lived (10 minutes) and stored as transients.
16 * This is more efficient than CPT for temporary data.
17 */
18 class Authorization_Code {
19 /**
20 * Transient prefix for authorization codes.
21 */
22 const TRANSIENT_PREFIX = 'activitypub_oauth_code_';
23
24 /**
25 * Authorization code expiration in seconds (10 minutes).
26 */
27 const EXPIRATION = 600;
28
29 /**
30 * Create a new authorization code.
31 *
32 * @param int $user_id WordPress user ID.
33 * @param string $client_id OAuth client ID.
34 * @param string $redirect_uri The redirect URI.
35 * @param array $scopes Requested scopes.
36 * @param string $code_challenge PKCE code challenge.
37 * @param string $code_challenge_method PKCE method (only S256 is supported).
38 * @return string|\WP_Error The authorization code or error.
39 */
40 public static function create(
41 $user_id,
42 $client_id,
43 $redirect_uri,
44 $scopes,
45 $code_challenge,
46 $code_challenge_method = 'S256'
47 ) {
48 $redirect_uri = Sanitize::redirect_uri( $redirect_uri );
49
50 // Validate client.
51 $client = Client::get( $client_id );
52 if ( \is_wp_error( $client ) ) {
53 return $client;
54 }
55
56 // Validate redirect URI.
57 if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
58 return new \WP_Error(
59 'activitypub_invalid_redirect_uri',
60 \__( 'Invalid redirect URI for this client.', 'activitypub' ),
61 array( 'status' => 400 )
62 );
63 }
64
65 /*
66 * PKCE is strongly recommended for public clients (RFC 7636) and
67 * mandatory in the OAuth 2.1 draft. It is enforced by default; site
68 * operators who must support pre-PKCE clients can opt out via the
69 * `activitypub_oauth_require_pkce` filter.
70 */
71 if ( empty( $code_challenge ) && $client->is_public() ) {
72 /**
73 * Filter whether PKCE is required for public OAuth clients.
74 *
75 * Return false to relax the default and allow public clients to
76 * complete the authorization code grant without PKCE. This is
77 * not recommended.
78 *
79 * @since 8.1.0
80 * @since 8.2.0 Default changed from false to true.
81 *
82 * @param bool $require Whether to require PKCE. Default true.
83 * @param string $client_id The OAuth client ID.
84 */
85 if ( \apply_filters( 'activitypub_oauth_require_pkce', true, $client_id ) ) {
86 return new \WP_Error(
87 'activitypub_pkce_required',
88 \__( 'PKCE is required for public clients. Please include a code_challenge parameter.', 'activitypub' ),
89 array( 'status' => 400 )
90 );
91 }
92 }
93
94 // Filter scopes to only allowed ones.
95 $filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) );
96
97 /*
98 * Nothing the client asked for is allowed to it. Refusing here is what RFC 6749 §4.1.2.1
99 * calls for, and it stops an empty grant from being minted: Token::create() would run the
100 * empty set back through Scope::validate(), which answers with the read-only default, so
101 * the client would end up holding `read` it was never granted.
102 */
103 if ( empty( $filtered_scopes ) ) {
104 return new \WP_Error(
105 'invalid_scope',
106 \__( 'The requested scopes are not allowed for this client.', 'activitypub' ),
107 array( 'status' => 400 )
108 );
109 }
110
111 // Generate the code.
112 $code = self::generate_code();
113 $code_hash = self::hash_code( $code );
114 $expires_at = \time() + self::EXPIRATION;
115
116 // Store code data in transient.
117 $code_data = array(
118 'user_id' => $user_id,
119 'client_id' => $client_id,
120 'redirect_uri' => $redirect_uri,
121 'scopes' => $filtered_scopes,
122 'code_challenge' => $code_challenge,
123 'code_challenge_method' => $code_challenge_method,
124 'expires_at' => $expires_at,
125 'created_at' => \time(),
126 );
127
128 $stored = \set_transient(
129 self::TRANSIENT_PREFIX . $code_hash,
130 $code_data,
131 self::EXPIRATION
132 );
133
134 if ( ! $stored ) {
135 return new \WP_Error(
136 'activitypub_code_storage_failed',
137 \__( 'Failed to store authorization code.', 'activitypub' ),
138 array( 'status' => 500 )
139 );
140 }
141
142 return $code;
143 }
144
145 /**
146 * Exchange authorization code for tokens.
147 *
148 * @param string $code The authorization code.
149 * @param string $client_id The client ID.
150 * @param string $redirect_uri The redirect URI (must match original).
151 * @param string $code_verifier The PKCE code verifier.
152 * @return array|\WP_Error Token data or error.
153 */
154 public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) {
155 $redirect_uri = Sanitize::redirect_uri( $redirect_uri );
156 $code_hash = self::hash_code( $code );
157 $transient = self::TRANSIENT_PREFIX . $code_hash;
158 $code_data = \get_transient( $transient );
159
160 if ( false === $code_data ) {
161 return new \WP_Error(
162 'activitypub_invalid_code',
163 \__( 'Invalid or expired authorization code.', 'activitypub' ),
164 array( 'status' => 400 )
165 );
166 }
167
168 // Immediately delete the code (single use).
169 \delete_transient( $transient );
170
171 // Check expiration (belt and suspenders - transient should auto-expire).
172 if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < \time() ) {
173 return new \WP_Error(
174 'activitypub_code_expired',
175 \__( 'Authorization code has expired.', 'activitypub' ),
176 array( 'status' => 400 )
177 );
178 }
179
180 // Verify client ID matches.
181 if ( $code_data['client_id'] !== $client_id ) {
182 return new \WP_Error(
183 'activitypub_client_mismatch',
184 \__( 'Client ID does not match.', 'activitypub' ),
185 array( 'status' => 400 )
186 );
187 }
188
189 // Verify redirect URI matches.
190 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
191 return new \WP_Error(
192 'activitypub_redirect_uri_mismatch',
193 \__( 'Redirect URI does not match.', 'activitypub' ),
194 array( 'status' => 400 )
195 );
196 }
197
198 // Verify PKCE.
199 $code_challenge = $code_data['code_challenge'] ?? '';
200 $code_challenge_method = $code_data['code_challenge_method'] ?? 'S256';
201
202 if ( ! self::verify_pkce( $code_verifier, $code_challenge, $code_challenge_method ) ) {
203 return new \WP_Error(
204 'activitypub_invalid_pkce',
205 \__( 'Invalid PKCE code verifier.', 'activitypub' ),
206 array( 'status' => 400 )
207 );
208 }
209
210 // Create and return the tokens.
211 return Token::create(
212 $code_data['user_id'],
213 $client_id,
214 $code_data['scopes']
215 );
216 }
217
218 /**
219 * Verify PKCE code_verifier against code_challenge.
220 *
221 * @param string $code_verifier The PKCE code verifier.
222 * @param string $code_challenge The stored code challenge.
223 * @param string $method The challenge method (only S256 is supported).
224 * @return bool True if valid.
225 */
226 public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
227 // If PKCE wasn't used during authorization (no challenge stored), skip verification.
228 if ( empty( $code_challenge ) ) {
229 return true;
230 }
231
232 // If challenge was provided but verifier is missing, fail.
233 if ( empty( $code_verifier ) ) {
234 return false;
235 }
236
237 // Only S256 is supported; reject anything else.
238 if ( 'S256' !== $method ) {
239 return false;
240 }
241
242 // S256: BASE64URL(SHA256(code_verifier)) == code_challenge.
243 $computed = self::compute_code_challenge( $code_verifier );
244
245 return \hash_equals( $code_challenge, $computed );
246 }
247
248 /**
249 * Compute a PKCE code challenge from a code verifier.
250 *
251 * @param string $code_verifier The code verifier.
252 * @return string The code challenge (BASE64URL encoded SHA256 hash).
253 */
254 public static function compute_code_challenge( $code_verifier ) {
255 $hash = \hash( 'sha256', $code_verifier, true );
256 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636.
257 return \rtrim( \strtr( \base64_encode( $hash ), '+/', '-_' ), '=' );
258 }
259
260 /**
261 * Generate a random authorization code.
262 *
263 * @return string The authorization code.
264 */
265 public static function generate_code() {
266 return \bin2hex( \random_bytes( 32 ) );
267 }
268
269 /**
270 * Hash an authorization code for storage lookup.
271 *
272 * @param string $code The authorization code.
273 * @return string The SHA-256 hash.
274 */
275 public static function hash_code( $code ) {
276 return \hash( 'sha256', $code );
277 }
278
279 /**
280 * Clean up expired authorization codes.
281 *
282 * Only deletes transients that have actually expired, to avoid breaking
283 * in-progress authorization flows.
284 *
285 * Note: Transients auto-expire, but this cleans up any orphaned ones.
286 * Should be called periodically via cron.
287 *
288 * @return int Number of codes deleted.
289 */
290 public static function cleanup() {
291 global $wpdb;
292
293 /*
294 * When an external object cache is active, transients are stored in
295 * the cache backend (Redis, Memcached, etc.) and auto-expire there.
296 * The direct SQL below only targets the options table, so skip it.
297 */
298 if ( \wp_using_ext_object_cache() ) {
299 return 0;
300 }
301
302 $timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX;
303 $now = \time();
304
305 // Find expired timeout rows for this prefix.
306 $timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
307 $wpdb->prepare(
308 "SELECT option_name FROM {$wpdb->options}
309 WHERE option_name LIKE %s
310 AND option_value < %d",
311 $wpdb->esc_like( $timeout_prefix ) . '%',
312 $now
313 )
314 );
315
316 if ( empty( $timeout_option_names ) ) {
317 return 0;
318 }
319
320 // Build list of timeout and corresponding value option names to delete.
321 $option_names_to_delete = array();
322 foreach ( $timeout_option_names as $timeout_name ) {
323 $option_names_to_delete[] = $timeout_name;
324 $option_names_to_delete[] = \str_replace( '_transient_timeout_', '_transient_', $timeout_name );
325 }
326
327 $placeholders = \implode( ', ', \array_fill( 0, \count( $option_names_to_delete ), '%s' ) );
328
329 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
330 $count = $wpdb->query(
331 $wpdb->prepare(
332 "DELETE FROM {$wpdb->options} WHERE option_name IN ( {$placeholders} )",
333 $option_names_to_delete
334 )
335 );
336 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
337
338 // Each transient has 2 rows (value + timeout).
339 return $count ? (int) ( $count / 2 ) : 0;
340 }
341 }
342