Newer
Older
wordpress-ojs-pugin / mu-plugins / guitex-oidc-well-known.php
<?php
/**
 * Plugin Name: GuIT OIDC Well-Known (JWKS + Discovery)
 * Description: Espone JWKS e OpenID discovery per OJS (pkp/openid) da OIDC_PUBLIC_KEY. Route REST (affidabile) + .well-known (se il rewrite passa da WordPress).
 * Version: 1.1.0
 * Author: GuIT
 *
 * Installazione: wp-content/mu-plugins/guitex-oidc-well-known.php
 * Richiede OIDC_PUBLIC_KEY in wp-config.php (generate-oidc-keys.php).
 *
 * Se /.well-known/* restituisce 404 nginx/apache (non WordPress), usa in OJS:
 *   certUrl:    https://TUO-DOMINIO/wp-json/guitex/v1/jwks
 *   configUrl:  https://TUO-DOMINIO/wp-json/guitex/v1/openid-configuration
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/** Query vars per rewrite .well-known → index.php */
const GUITEX_OIDC_QV_JWKS      = 'guitex_oidc_jwks';
const GUITEX_OIDC_QV_DISCOVERY = 'guitex_oidc_discovery';

add_action( 'plugins_loaded', 'guitex_oidc_well_known_maybe_serve_path', 0 );
add_filter( 'query_vars', 'guitex_oidc_well_known_query_vars' );
add_action( 'init', 'guitex_oidc_well_known_register_rewrites', 0 );
add_action( 'parse_request', 'guitex_oidc_well_known_parse_request', 0 );
add_action( 'rest_api_init', 'guitex_oidc_well_known_register_rest_routes' );

/**
 * Intercetta path /.well-known/* quando la richiesta arriva a WordPress.
 *
 * @return void
 */
function guitex_oidc_well_known_maybe_serve_path() {
	$kind = guitex_oidc_well_known_request_kind();
	if ( $kind === 'jwks' ) {
		guitex_oidc_well_known_send_jwks();
	}
	if ( $kind === 'discovery' ) {
		guitex_oidc_well_known_send_discovery();
	}
}

/**
 * @param WP $wp WordPress request object.
 * @return void
 */
function guitex_oidc_well_known_parse_request( $wp ) {
	if ( ! empty( $wp->query_vars[ GUITEX_OIDC_QV_JWKS ] ) ) {
		guitex_oidc_well_known_send_jwks();
	}
	if ( ! empty( $wp->query_vars[ GUITEX_OIDC_QV_DISCOVERY ] ) ) {
		guitex_oidc_well_known_send_discovery();
	}
}

/**
 * @param array<int, string> $vars Query vars.
 * @return array<int, string>
 */
function guitex_oidc_well_known_query_vars( $vars ) {
	$vars[] = GUITEX_OIDC_QV_JWKS;
	$vars[] = GUITEX_OIDC_QV_DISCOVERY;
	return $vars;
}

/**
 * Rewrite opzionale: richiede salvataggio permalink (Impostazioni → Permalink → Salva).
 *
 * @return void
 */
function guitex_oidc_well_known_register_rewrites() {
	add_rewrite_rule(
		'^\.well-known/jwks\.json$',
		'index.php?' . GUITEX_OIDC_QV_JWKS . '=1',
		'top'
	);
	add_rewrite_rule(
		'^\.well-known/openid-configuration$',
		'index.php?' . GUITEX_OIDC_QV_DISCOVERY . '=1',
		'top'
	);
}

/**
 * Endpoint REST — funzionano anche quando il server blocca /.well-known/.
 *
 * @return void
 */
function guitex_oidc_well_known_register_rest_routes() {
	register_rest_route(
		'guitex/v1',
		'/jwks',
		[
			'methods'             => 'GET',
			'callback'            => 'guitex_oidc_well_known_rest_jwks',
			'permission_callback' => '__return_true',
		]
	);

	register_rest_route(
		'guitex/v1',
		'/openid-configuration',
		[
			'methods'             => 'GET',
			'callback'            => 'guitex_oidc_well_known_rest_discovery',
			'permission_callback' => '__return_true',
		]
	);
}

/**
 * @return WP_REST_Response|WP_Error
 */
function guitex_oidc_well_known_rest_jwks() {
	$payload = guitex_oidc_well_known_jwks_payload();
	if ( is_wp_error( $payload ) ) {
		return $payload;
	}
	$response = new WP_REST_Response( $payload, 200 );
	$response->header( 'Cache-Control', 'public, max-age=3600' );
	return $response;
}

/**
 * @return WP_REST_Response
 */
function guitex_oidc_well_known_rest_discovery() {
	$response = new WP_REST_Response( guitex_oidc_well_known_discovery_payload(), 200 );
	$response->header( 'Cache-Control', 'public, max-age=300' );
	return $response;
}

/**
 * @return 'jwks'|'discovery'|null
 */
function guitex_oidc_well_known_request_kind() {
	$uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
	if ( $uri === '' ) {
		return null;
	}

	$path = (string) parse_url( $uri, PHP_URL_PATH );
	$path = rtrim( $path, '/' );

	$home_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
	$home_path = rtrim( $home_path, '/' );
	if ( $home_path !== '' && strpos( $path, $home_path ) === 0 ) {
		$path = substr( $path, strlen( $home_path ) );
		if ( $path === '' ) {
			$path = '/';
		}
	}

	$suffixes = [
		'/.well-known/jwks.json'        => 'jwks',
		'/index.php/.well-known/jwks.json' => 'jwks',
		'/.well-known/openid-configuration' => 'discovery',
		'/index.php/.well-known/openid-configuration' => 'discovery',
	];

	return $suffixes[ $path ] ?? null;
}

/**
 * @return void
 */
function guitex_oidc_well_known_send_jwks() {
	$payload = guitex_oidc_well_known_jwks_payload();
	if ( is_wp_error( $payload ) ) {
		guitex_oidc_well_known_json_error( $payload->get_error_message(), 503 );
	}

	$body = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES );
	guitex_oidc_well_known_send_json( $body, 200, 3600 );
}

/**
 * @return void
 */
function guitex_oidc_well_known_send_discovery() {
	$body = wp_json_encode( guitex_oidc_well_known_discovery_payload(), JSON_UNESCAPED_SLASHES );
	guitex_oidc_well_known_send_json( $body, 200, 300 );
}

/**
 * @return array{keys: array<int, array<string, string>>}|WP_Error
 */
function guitex_oidc_well_known_jwks_payload() {
	if ( ! defined( 'OIDC_PUBLIC_KEY' ) || OIDC_PUBLIC_KEY === '' ) {
		return new WP_Error(
			'oidc_no_public_key',
			'OIDC_PUBLIC_KEY non definita in wp-config.php',
			[ 'status' => 503 ]
		);
	}

	$jwk = guitex_oidc_well_known_pem_to_jwk( OIDC_PUBLIC_KEY );
	if ( $jwk === null ) {
		return new WP_Error(
			'oidc_invalid_public_key',
			'Impossibile leggere OIDC_PUBLIC_KEY (formato PEM non valido)',
			[ 'status' => 503 ]
		);
	}

	return [ 'keys' => [ $jwk ] ];
}

/**
 * @return array<string, mixed>
 */
function guitex_oidc_well_known_discovery_payload() {
	$issuer   = guitex_oidc_well_known_issuer();
	$jwks_uri = guitex_oidc_well_known_jwks_uri();

	return [
		'issuer'                                => $issuer,
		'authorization_endpoint'                => rest_url( 'openid-connect/authorize' ),
		'token_endpoint'                        => rest_url( 'openid-connect/token' ),
		'userinfo_endpoint'                     => rest_url( 'openid-connect/userinfo' ),
		'jwks_uri'                              => $jwks_uri,
		'response_types_supported'              => [ 'code' ],
		'grant_types_supported'                 => [ 'authorization_code' ],
		'subject_types_supported'               => [ 'public' ],
		'id_token_signing_alg_values_supported' => [ 'RS256' ],
		'scopes_supported'                      => [ 'openid', 'profile', 'email' ],
	];
}

/**
 * URI JWKS consigliata (REST se .well-known non raggiungibile).
 *
 * @return string
 */
function guitex_oidc_well_known_jwks_uri() {
	return rest_url( 'guitex/v1/jwks' );
}

/**
 * @param string $pem PEM public key.
 * @return array<string, string>|null
 */
function guitex_oidc_well_known_pem_to_jwk( $pem ) {
	if ( ! function_exists( 'openssl_pkey_get_public' ) ) {
		return null;
	}

	$key = openssl_pkey_get_public( $pem );
	if ( $key === false ) {
		return null;
	}

	$details = openssl_pkey_get_details( $key );
	if ( ! is_array( $details ) || ( $details['type'] ?? null ) !== OPENSSL_KEYTYPE_RSA ) {
		return null;
	}

	$n = $details['rsa']['n'] ?? '';
	$e = $details['rsa']['e'] ?? '';
	if ( $n === '' || $e === '' ) {
		return null;
	}

	$n_b64 = guitex_oidc_well_known_base64url( $n );

	return [
		'kty' => 'RSA',
		'alg' => 'RS256',
		'use' => 'sig',
		'kid' => substr( hash( 'sha256', $n_b64 ), 0, 16 ),
		'n'   => $n_b64,
		'e'   => guitex_oidc_well_known_base64url( $e ),
	];
}

/**
 * @param string $data Raw binary.
 * @return string
 */
function guitex_oidc_well_known_base64url( $data ) {
	return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
}

/**
 * @return string
 */
function guitex_oidc_well_known_issuer() {
	return rtrim( home_url(), '/' );
}

/**
 * @param string $body JSON.
 * @param int    $status HTTP status.
 * @param int    $max_age Cache seconds.
 * @return void
 */
function guitex_oidc_well_known_send_json( $body, $status, $max_age ) {
	if ( ! headers_sent() ) {
		status_header( $status );
		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'X-Robots-Tag: noindex' );
		header( $max_age > 0 ? 'Cache-Control: public, max-age=' . (int) $max_age : 'Cache-Control: no-store' );
	}

	echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	exit;
}

/**
 * @param string $message Error text.
 * @param int    $status HTTP status.
 * @return void
 */
function guitex_oidc_well_known_json_error( $message, $status ) {
	$body = wp_json_encode(
		[
			'error'             => 'configuration_error',
			'error_description' => $message,
		],
		JSON_UNESCAPED_SLASHES
	);
	guitex_oidc_well_known_send_json( $body, $status, 0 );
}