'signin', 'return_to' => $return_to, 'scope' => $scopes ] ); return add_query_arg( array_map( 'rawurlencode', $args ), home_url( '/' ) ); } public static function signout_url( string $return_to = '' ): string { $args = array_filter( [ 'vimine_action' => 'signout', 'return_to' => $return_to, '_wpnonce' => wp_create_nonce( 'vimine_signout' ) ] ); return add_query_arg( array_map( 'rawurlencode', $args ), home_url( '/' ) ); } public static function route(): void { if ( ! Vimine_Settings::configured() ) { return; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; $path = trim( (string) wp_parse_url( $uri, PHP_URL_PATH ), '/' ); $cb = trim( Vimine_Settings::get()['callback_path'], '/' ); if ( $path === $cb || str_ends_with( $path, '/' . $cb ) ) { self::callback(); return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only picks the leg (see the class comment). $action = isset( $_GET['vimine_action'] ) ? sanitize_key( wp_unslash( $_GET['vimine_action'] ) ) : ''; if ( 'signin' === $action ) { self::start(); } elseif ( 'signout' === $action ) { self::signout(); } } /** `return_to` of this request: a path on this site, or ''. */ private static function return_arg(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sign-in carries no nonce (see the class comment); sign-out has checked its own. return isset( $_GET['return_to'] ) && is_string( $_GET['return_to'] ) ? esc_url_raw( wp_unslash( $_GET['return_to'] ) ) : ''; } /** A path relative to the site's home ('/members/') as a URL on this site; anything else is the home page. */ private static function safe_return( string $raw ): string { $raw = trim( $raw ); if ( '' === $raw || ! str_starts_with( $raw, '/' ) || str_starts_with( $raw, '//' ) ) { return home_url( '/' ); } return home_url( $raw ); } private static function start(): void { $verifier = Vimine_Client::pkce_verifier(); $state = bin2hex( random_bytes( 16 ) ); $flow_id = bin2hex( random_bytes( 12 ) ); set_transient( 'vimine_flow_' . $flow_id, [ 'state' => $state, 'verifier' => $verifier, 'return_to' => self::safe_return( self::return_arg() ), ], 10 * MINUTE_IN_SECONDS ); setcookie( self::FLOW_COOKIE, $flow_id, [ 'expires' => time() + 600, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax' ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- sign-in carries no nonce (see the class comment). $scopes = isset( $_GET['scope'] ) ? sanitize_text_field( wp_unslash( $_GET['scope'] ) ) : ''; $client = new Vimine_Client(); self::to_vimine( $client->authorize_url( $state, Vimine_Client::pkce_challenge( $verifier ), '' !== $scopes ? $scopes : null ) ); } private static function callback(): void { $flow_id = isset( $_COOKIE[ self::FLOW_COOKIE ] ) ? sanitize_key( wp_unslash( $_COOKIE[ self::FLOW_COOKIE ] ) ) : ''; $flow = '' !== $flow_id ? get_transient( 'vimine_flow_' . $flow_id ) : null; if ( '' !== $flow_id ) { delete_transient( 'vimine_flow_' . $flow_id ); setcookie( self::FLOW_COOKIE, '', [ 'expires' => time() - 3600, 'path' => '/' ] ); } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Vimine's redirect back: `state` is checked against this browser's flow first (see the class comment). $state = isset( $_GET['state'] ) ? sanitize_key( wp_unslash( $_GET['state'] ) ) : ''; $error = isset( $_GET['error'] ) ? sanitize_key( wp_unslash( $_GET['error'] ) ) : null; $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $return_to = is_array( $flow ) ? $flow['return_to'] : home_url( '/' ); if ( ! is_array( $flow ) || '' === $state || ! hash_equals( $flow['state'], $state ) ) { wp_die( esc_html__( 'Invalid or expired sign-in: please try again.', 'vimine' ), 400 ); } if ( null !== $error ) { // access_denied etc.: the member cancelled on the hosted page — back to where they were. wp_safe_redirect( add_query_arg( 'vimine_error', $error, $return_to ) ); exit; } $client = new Vimine_Client(); $tokens = $client->exchange_code( $code, $flow['verifier'] ); if ( is_wp_error( $tokens ) ) { wp_die( esc_html( sprintf( /* translators: 1: the error code Vimine returned, 2: its description (may be empty) */ __( 'The sign-in with Vimine could not be completed (%1$s). %2$s', 'vimine' ), $tokens->get_error_code(), $tokens->get_error_message() ) ), 502 ); } Vimine_Session::start( $tokens ); wp_safe_redirect( $return_to ); exit; } private static function signout(): void { if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'vimine_signout' ) ) { wp_die( esc_html__( 'Invalid request.', 'vimine' ), 400 ); } $return_to = self::safe_return( self::return_arg() ); $s = Vimine_Session::get(); $client = new Vimine_Client(); if ( $s && ! empty( $s['refresh_token'] ) ) { $client->revoke( $s['refresh_token'] ); } Vimine_Session::end(); // Single sign-out: the app host ends the Vimine session and sends the visitor back here. self::to_vimine( $client->logout_url( $return_to ) ); } /** Off to a hosted page on the app host: wp_safe_redirect(), with that host allowed for this redirect only. */ private static function to_vimine( string $url ): void { add_filter( 'allowed_redirect_hosts', [ __CLASS__, 'allow_app_host' ] ); wp_safe_redirect( $url ); exit; } /** * @param string[] $hosts * @return string[] */ public static function allow_app_host( $hosts ): array { $host = wp_parse_url( Vimine_Settings::get()['app_url'], PHP_URL_HOST ); return array_merge( (array) $hosts, is_string( $host ) && '' !== $host ? [ $host ] : [] ); } }