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

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