PluginProbe
ActivityPub / 8.2.1
ActivityPub v8.2.1
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 8.2.1, at includes/oauth/class-authorization-code.php

328 lines 9.8 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 // Generate the code.
98 $code = self::generate_code();
99 $code_hash = self::hash_code( $code );
100 $expires_at = time() + self::EXPIRATION;
101
102 // Store code data in transient.
103 $code_data = array(
104 'user_id' => $user_id,
105 'client_id' => $client_id,
106 'redirect_uri' => $redirect_uri,
107 'scopes' => $filtered_scopes,
108 'code_challenge' => $code_challenge,
109 'code_challenge_method' => $code_challenge_method,
110 'expires_at' => $expires_at,
111 'created_at' => time(),
112 );
113
114 $stored = \set_transient(
115 self::TRANSIENT_PREFIX . $code_hash,
116 $code_data,
117 self::EXPIRATION
118 );
119
120 if ( ! $stored ) {
121 return new \WP_Error(
122 'activitypub_code_storage_failed',
123 \__( 'Failed to store authorization code.', 'activitypub' ),
124 array( 'status' => 500 )
125 );
126 }
127
128 return $code;
129 }
130
131 /**
132 * Exchange authorization code for tokens.
133 *
134 * @param string $code The authorization code.
135 * @param string $client_id The client ID.
136 * @param string $redirect_uri The redirect URI (must match original).
137 * @param string $code_verifier The PKCE code verifier.
138 * @return array|\WP_Error Token data or error.
139 */
140 public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) {
141 $redirect_uri = Sanitize::redirect_uri( $redirect_uri );
142 $code_hash = self::hash_code( $code );
143 $transient = self::TRANSIENT_PREFIX . $code_hash;
144 $code_data = \get_transient( $transient );
145
146 if ( false === $code_data ) {
147 return new \WP_Error(
148 'activitypub_invalid_code',
149 \__( 'Invalid or expired authorization code.', 'activitypub' ),
150 array( 'status' => 400 )
151 );
152 }
153
154 // Immediately delete the code (single use).
155 \delete_transient( $transient );
156
157 // Check expiration (belt and suspenders - transient should auto-expire).
158 if ( isset( $code_data['expires_at'] ) && $code_data['expires_at'] < time() ) {
159 return new \WP_Error(
160 'activitypub_code_expired',
161 \__( 'Authorization code has expired.', 'activitypub' ),
162 array( 'status' => 400 )
163 );
164 }
165
166 // Verify client ID matches.
167 if ( $code_data['client_id'] !== $client_id ) {
168 return new \WP_Error(
169 'activitypub_client_mismatch',
170 \__( 'Client ID does not match.', 'activitypub' ),
171 array( 'status' => 400 )
172 );
173 }
174
175 // Verify redirect URI matches.
176 if ( $code_data['redirect_uri'] !== $redirect_uri ) {
177 return new \WP_Error(
178 'activitypub_redirect_uri_mismatch',
179 \__( 'Redirect URI does not match.', 'activitypub' ),
180 array( 'status' => 400 )
181 );
182 }
183
184 // Verify PKCE.
185 $code_challenge = $code_data['code_challenge'] ?? '';
186 $code_challenge_method = $code_data['code_challenge_method'] ?? 'S256';
187
188 if ( ! self::verify_pkce( $code_verifier, $code_challenge, $code_challenge_method ) ) {
189 return new \WP_Error(
190 'activitypub_invalid_pkce',
191 \__( 'Invalid PKCE code verifier.', 'activitypub' ),
192 array( 'status' => 400 )
193 );
194 }
195
196 // Create and return the tokens.
197 return Token::create(
198 $code_data['user_id'],
199 $client_id,
200 $code_data['scopes']
201 );
202 }
203
204 /**
205 * Verify PKCE code_verifier against code_challenge.
206 *
207 * @param string $code_verifier The PKCE code verifier.
208 * @param string $code_challenge The stored code challenge.
209 * @param string $method The challenge method (only S256 is supported).
210 * @return bool True if valid.
211 */
212 public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
213 // If PKCE wasn't used during authorization (no challenge stored), skip verification.
214 if ( empty( $code_challenge ) ) {
215 return true;
216 }
217
218 // If challenge was provided but verifier is missing, fail.
219 if ( empty( $code_verifier ) ) {
220 return false;
221 }
222
223 // Only S256 is supported; reject anything else.
224 if ( 'S256' !== $method ) {
225 return false;
226 }
227
228 // S256: BASE64URL(SHA256(code_verifier)) == code_challenge.
229 $computed = self::compute_code_challenge( $code_verifier );
230
231 return hash_equals( $code_challenge, $computed );
232 }
233
234 /**
235 * Compute a PKCE code challenge from a code verifier.
236 *
237 * @param string $code_verifier The code verifier.
238 * @return string The code challenge (BASE64URL encoded SHA256 hash).
239 */
240 public static function compute_code_challenge( $code_verifier ) {
241 $hash = hash( 'sha256', $code_verifier, true );
242 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PKCE BASE64URL encoding per RFC 7636.
243 return rtrim( strtr( base64_encode( $hash ), '+/', '-_' ), '=' );
244 }
245
246 /**
247 * Generate a random authorization code.
248 *
249 * @return string The authorization code.
250 */
251 public static function generate_code() {
252 return bin2hex( random_bytes( 32 ) );
253 }
254
255 /**
256 * Hash an authorization code for storage lookup.
257 *
258 * @param string $code The authorization code.
259 * @return string The SHA-256 hash.
260 */
261 public static function hash_code( $code ) {
262 return hash( 'sha256', $code );
263 }
264
265 /**
266 * Clean up expired authorization codes.
267 *
268 * Only deletes transients that have actually expired, to avoid breaking
269 * in-progress authorization flows.
270 *
271 * Note: Transients auto-expire, but this cleans up any orphaned ones.
272 * Should be called periodically via cron.
273 *
274 * @return int Number of codes deleted.
275 */
276 public static function cleanup() {
277 global $wpdb;
278
279 /*
280 * When an external object cache is active, transients are stored in
281 * the cache backend (Redis, Memcached, etc.) and auto-expire there.
282 * The direct SQL below only targets the options table, so skip it.
283 */
284 if ( \wp_using_ext_object_cache() ) {
285 return 0;
286 }
287
288 $timeout_prefix = '_transient_timeout_' . self::TRANSIENT_PREFIX;
289 $now = time();
290
291 // Find expired timeout rows for this prefix.
292 $timeout_option_names = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
293 $wpdb->prepare(
294 "SELECT option_name FROM {$wpdb->options}
295 WHERE option_name LIKE %s
296 AND option_value < %d",
297 $wpdb->esc_like( $timeout_prefix ) . '%',
298 $now
299 )
300 );
301
302 if ( empty( $timeout_option_names ) ) {
303 return 0;
304 }
305
306 // Build list of timeout and corresponding value option names to delete.
307 $option_names_to_delete = array();
308 foreach ( $timeout_option_names as $timeout_name ) {
309 $option_names_to_delete[] = $timeout_name;
310 $option_names_to_delete[] = str_replace( '_transient_timeout_', '_transient_', $timeout_name );
311 }
312
313 $placeholders = implode( ', ', array_fill( 0, count( $option_names_to_delete ), '%s' ) );
314
315 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
316 $count = $wpdb->query(
317 $wpdb->prepare(
318 "DELETE FROM {$wpdb->options} WHERE option_name IN ( {$placeholders} )",
319 $option_names_to_delete
320 )
321 );
322 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery
323
324 // Each transient has 2 rows (value + timeout).
325 return $count ? (int) ( $count / 2 ) : 0;
326 }
327 }
328