PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.4.1 4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / inc / Services / OAuth / Consent / ConsentController.php
presto-player / inc / Services / OAuth / Consent Last commit date
templates 1 week ago ConsentController.php 1 week ago
ConsentController.php
247 lines
1 <?php
2 /**
3 * Consent screen renderer + submit handler for OAuth /authorize.
4 *
5 * Rendering is intentionally standalone (no theme or wp_head/wp_footer):
6 * the consent page is a security UI and must not be influenced by theme
7 * markup, third-party scripts, or admin chrome.
8 *
9 * @package PrestoPlayer
10 * @subpackage Services\OAuth\Consent
11 */
12
13 namespace PrestoPlayer\Services\OAuth\Consent;
14
15 use PrestoPlayer\Services\OAuth\Constants;
16
17 /**
18 * Renders the consent screen and handles the POSTed user decision.
19 */
20 class ConsentController {
21
22 /**
23 * Human-readable copy for each known scope.
24 *
25 * @return array<string, string>
26 */
27 protected function scopeDescriptions() {
28 return array(
29 Constants::SCOPE_READ => __( 'View videos, presets, analytics, and settings', 'presto-player' ),
30 Constants::SCOPE_WRITE => __( 'Create and edit videos, presets, captions', 'presto-player' ),
31 Constants::SCOPE_DESTRUCTIVE => __( 'Delete videos, presets, and submissions', 'presto-player' ),
32 Constants::SCOPE_ADMIN => __( 'Modify plugin settings and license', 'presto-player' ),
33 );
34 }
35
36 /**
37 * Build the nonce action string for a given client/state pair.
38 *
39 * @param string $client_id Client identifier.
40 * @param string $state OAuth opaque state.
41 * @param string $scope Space-delimited scope shown on the consent screen.
42 * @param string $redirect_uri Redirect URI shown on the consent screen.
43 * @return string
44 */
45 protected function nonceAction( $client_id, $state, $scope = '', $redirect_uri = '' ) {
46 // Bind the nonce to the exact scope + redirect_uri that were displayed, so a
47 // tampered POST that swaps the scope after the user consented fails to verify.
48 $binding = wp_hash( $client_id . '|' . $state . '|' . trim( (string) $scope ) . '|' . $redirect_uri );
49 return 'presto_oauth_consent_' . $binding;
50 }
51
52 /**
53 * Render the consent HTML page.
54 *
55 * Expects validated params from {@see AuthorizeEndpoint}:
56 * - client_id, client_name, redirect_uri, scope, state,
57 * code_challenge, code_challenge_method, response_type.
58 *
59 * @param array<string, string> $params Validated params.
60 * @return void
61 */
62 public function renderConsent( array $params ) {
63 $user = wp_get_current_user();
64
65 $scope_slugs = preg_split( '/\s+/', trim( (string) $params['scope'] ) );
66 $scope_slugs = is_array( $scope_slugs ) ? array_values(
67 array_filter(
68 $scope_slugs,
69 static function ( $s ) {
70 return '' !== (string) $s;
71 }
72 )
73 ) : array();
74 $descriptions = $this->scopeDescriptions();
75
76 $scopes = array();
77 foreach ( $scope_slugs as $slug ) {
78 $scopes[] = array(
79 'slug' => $slug,
80 'description' => isset( $descriptions[ $slug ] ) ? $descriptions[ $slug ] : $slug,
81 );
82 }
83
84 $client_name = isset( $params['client_name'] ) ? $params['client_name'] : $params['client_id'];
85 $user_email = $user && ! empty( $user->user_email ) ? $user->user_email : '';
86 $site_name = get_bloginfo( 'name' );
87
88 // Registration is open and client_name is whatever the client called itself, so the
89 // name alone can impersonate anything. The redirect target is the one thing an
90 // attacker can't fake — it's where the token actually goes — so show it.
91 $redirect_uri = isset( $params['redirect_uri'] ) ? (string) $params['redirect_uri'] : '';
92
93 $action = $this->nonceAction( $params['client_id'], $params['state'], $params['scope'], $params['redirect_uri'] );
94 $nonce_field = wp_nonce_field( $action, '_wpnonce', true, false );
95 $form_action = $this->buildFormAction( $params );
96
97 status_header( 200 );
98 nocache_headers();
99 if ( ! headers_sent() ) {
100 header( 'Content-Type: text/html; charset=utf-8' );
101 header( 'X-Frame-Options: DENY' );
102 header( "Content-Security-Policy: frame-ancestors 'none'" );
103 }
104
105 $template = __DIR__ . '/templates/consent.php';
106 include $template;
107 }
108
109 /**
110 * Build the same-URL form action carrying every original query parameter.
111 *
112 * Hidden inputs add the user's decision (`allow`/`deny`) and the nonce.
113 *
114 * @param array<string, string> $params Validated params.
115 * @return string Absolute URL ready for the form's `action` attribute.
116 */
117 protected function buildFormAction( array $params ) {
118 $base = home_url( Constants::AUTHORIZE_PATH );
119 $args = array(
120 'response_type' => $params['response_type'],
121 'client_id' => $params['client_id'],
122 'redirect_uri' => $params['redirect_uri'],
123 'state' => $params['state'],
124 'code_challenge' => $params['code_challenge'],
125 'code_challenge_method' => $params['code_challenge_method'],
126 'scope' => $params['scope'],
127 );
128 return add_query_arg( array_map( 'rawurlencode', $args ), $base );
129 }
130
131 /**
132 * Process the POSTed consent decision.
133 *
134 * - Verifies the nonce keyed to the client+state.
135 * - On "deny": redirects back to redirect_uri with access_denied.
136 * - On "allow": issues a fresh authorization code and redirects with
137 * ?code=...&state=... per RFC 6749 §4.1.2.
138 *
139 * @param array<string, string> $params Validated params (pre-checked by AuthorizeEndpoint).
140 * @return void
141 */
142 public function handleConsentSubmit( array $params ) {
143 $action = $this->nonceAction( $params['client_id'], $params['state'], $params['scope'], $params['redirect_uri'] );
144 $nonce_value = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified on the next line.
145
146 if ( ! wp_verify_nonce( $nonce_value, $action ) ) {
147 $this->redirectError( $params['redirect_uri'], 'invalid_request', $params['state'], __( 'Nonce verification failed.', 'presto-player' ) );
148 return;
149 }
150
151 $decision = isset( $_POST['allow'] ) ? 'allow' : ( isset( $_POST['deny'] ) ? 'deny' : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
152
153 if ( 'deny' === $decision ) {
154 $this->redirectError( $params['redirect_uri'], 'access_denied', $params['state'], __( 'The user denied the authorization request.', 'presto-player' ) );
155 return;
156 }
157
158 if ( 'allow' !== $decision ) {
159 $this->redirectError( $params['redirect_uri'], 'invalid_request', $params['state'], __( 'Missing consent decision.', 'presto-player' ) );
160 return;
161 }
162
163 $scopes = preg_split( '/\s+/', trim( (string) $params['scope'] ) );
164 $scopes = is_array( $scopes ) ? array_values(
165 array_filter(
166 $scopes,
167 static function ( $s ) {
168 return '' !== (string) $s;
169 }
170 )
171 ) : array();
172
173 $repo = new \PrestoPlayer\Services\OAuth\Storage\CodeRepository();
174 $code = $repo->issue(
175 $params['client_id'],
176 (int) get_current_user_id(),
177 $scopes,
178 $params['redirect_uri'],
179 '' === $params['code_challenge'] ? null : $params['code_challenge'],
180 '' === $params['code_challenge_method'] ? null : $params['code_challenge_method'],
181 Constants::AUTH_CODE_TTL
182 );
183
184 // An empty code means the hash never persisted; don't redirect with a broken code.
185 if ( '' === $code ) {
186 $this->redirectError( $params['redirect_uri'], 'server_error', $params['state'], __( 'Could not issue the authorization code.', 'presto-player' ) );
187 return;
188 }
189
190 $query = array( 'code' => rawurlencode( $code ) );
191 if ( '' !== (string) $params['state'] ) {
192 $query['state'] = rawurlencode( $params['state'] );
193 }
194
195 $url = add_query_arg( $query, $params['redirect_uri'] );
196
197 $this->finalRedirect( $url );
198 exit;
199 }
200
201 /**
202 * Build + emit the error redirect.
203 *
204 * @param string $redirect_uri Validated redirect URI.
205 * @param string $error OAuth error code.
206 * @param string $state Opaque state, echoed back when the client sent one.
207 * @param string $description Human-readable error description.
208 * @return void
209 */
210 protected function redirectError( $redirect_uri, $error, $state, $description ) {
211 $query = array(
212 'error' => rawurlencode( $error ),
213 'error_description' => rawurlencode( $description ),
214 );
215
216 // RFC 6749 §4.1.2.1: only echo state back if the request carried one.
217 if ( '' !== (string) $state ) {
218 $query['state'] = rawurlencode( $state );
219 }
220
221 $url = add_query_arg( $query, $redirect_uri );
222 $this->finalRedirect( $url );
223 exit;
224 }
225
226 /**
227 * Choose safe vs. allowlisted redirect.
228 *
229 * Same-host targets go through wp_safe_redirect. Cross-host targets
230 * use wp_redirect — safe here because redirect_uri was already
231 * validated against the client's allowlist.
232 *
233 * @param string $url Absolute URL.
234 * @return void
235 */
236 protected function finalRedirect( $url ) {
237 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
238 $target_host = wp_parse_url( $url, PHP_URL_HOST );
239
240 if ( $site_host && $target_host && strtolower( $site_host ) === strtolower( $target_host ) ) {
241 wp_safe_redirect( $url );
242 return;
243 }
244 wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
245 }
246 }
247